nmem-cli 0.7.6__tar.gz → 0.7.9__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.7.9/.gitignore +1 -0
  2. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/PKG-INFO +1 -1
  3. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/pyproject.toml +1 -1
  4. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/__init__.py +1 -1
  5. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/cli.py +421 -42
  6. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/session_import.py +64 -24
  7. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/tui/api_client.py +1 -0
  8. nmem_cli-0.7.6/.gitignore +0 -5
  9. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/README.md +0 -0
  10. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/license_payload.py +0 -0
  11. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/py.typed +0 -0
  12. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/tui/__init__.py +0 -0
  13. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/tui/__main__.py +0 -0
  14. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/tui/app.py +0 -0
  15. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/tui/screens/__init__.py +0 -0
  16. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/tui/screens/dashboard.py +0 -0
  17. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/tui/screens/graph.py +0 -0
  18. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/tui/screens/help.py +0 -0
  19. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/tui/screens/memories.py +0 -0
  20. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/tui/screens/memory_detail.py +0 -0
  21. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/tui/screens/settings.py +0 -0
  22. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/tui/screens/thread_detail.py +0 -0
  23. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/tui/screens/threads.py +0 -0
  24. {nmem_cli-0.7.6 → nmem_cli-0.7.9}/src/nmem_cli/tui/widgets/__init__.py +0 -0
@@ -0,0 +1 @@
1
+ config.env
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nmem-cli
3
- Version: 0.7.6
3
+ Version: 0.7.9
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.7.6"
3
+ version = "0.7.9"
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.7.6"
23
+ __version__ = "0.7.9"
24
24
  __author__ = "Nowledge Labs"
25
25
 
26
26
  from .cli import main
@@ -557,6 +557,43 @@ def _is_loopback_hostname(hostname: str | None) -> bool:
557
557
  return False
558
558
 
559
559
 
560
+ def _httpx_transport_kwargs(url: str) -> dict[str, Any]:
561
+ """Disable environment proxy settings for loopback URLs.
562
+
563
+ Windows proxy configuration can incorrectly capture localhost traffic for
564
+ CLI/TUI requests. Loopback calls should always go directly to the local Mem
565
+ server instead of honoring system proxy settings.
566
+ """
567
+ try:
568
+ hostname = urlsplit(url).hostname
569
+ except Exception:
570
+ hostname = None
571
+ return {"trust_env": False} if _is_loopback_hostname(hostname) else {}
572
+
573
+
574
+ def _merge_httpx_kwargs(url: str, kwargs: dict[str, Any]) -> dict[str, Any]:
575
+ merged = dict(kwargs)
576
+ for key, value in _httpx_transport_kwargs(url).items():
577
+ merged.setdefault(key, value)
578
+ return merged
579
+
580
+
581
+ def _httpx_request(method: str, url: str, **kwargs: Any) -> httpx.Response:
582
+ return httpx.request(method, url, **_merge_httpx_kwargs(url, kwargs))
583
+
584
+
585
+ def _httpx_get(url: str, **kwargs: Any) -> httpx.Response:
586
+ return httpx.get(url, **_merge_httpx_kwargs(url, kwargs))
587
+
588
+
589
+ def _httpx_post(url: str, **kwargs: Any) -> httpx.Response:
590
+ return httpx.post(url, **_merge_httpx_kwargs(url, kwargs))
591
+
592
+
593
+ def _httpx_stream(method: str, url: str, **kwargs: Any):
594
+ return httpx.stream(method, url, **_merge_httpx_kwargs(url, kwargs))
595
+
596
+
560
597
  def _iter_local_management_base_urls() -> list[str]:
561
598
  candidates: list[str] = []
562
599
  seen: set[str] = set()
@@ -585,7 +622,7 @@ def _iter_local_management_base_urls() -> list[str]:
585
622
  def _request_local_remote_access_management(path: str) -> dict[str, Any] | None:
586
623
  for base_url in _iter_local_management_base_urls():
587
624
  try:
588
- response = httpx.post(f"{base_url}{path}", timeout=10.0)
625
+ response = _httpx_post(f"{base_url}{path}", timeout=10.0)
589
626
  except httpx.RequestError:
590
627
  continue
591
628
 
@@ -744,7 +781,7 @@ def api_request(
744
781
  merged_headers.update(headers)
745
782
 
746
783
  cookies = get_api_cookies() or None
747
- response = httpx.request(
784
+ response = _httpx_request(
748
785
  method,
749
786
  url,
750
787
  params=params,
@@ -760,7 +797,7 @@ def api_request(
760
797
  # If auth headers are stripped in transit, retry once with query-key fallback.
761
798
  if get_api_key() and _is_missing_api_key_response(response):
762
799
  retry_params = _build_params_with_query_key(params)
763
- response = httpx.request(
800
+ response = _httpx_request(
764
801
  method,
765
802
  url,
766
803
  params=retry_params,
@@ -779,7 +816,7 @@ def api_request(
779
816
  compat_url = _strip_remote_api_path_prefix(url)
780
817
  if compat_url != url:
781
818
  retry_params = _build_params_with_query_key(params)
782
- response = httpx.request(
819
+ response = _httpx_request(
783
820
  method,
784
821
  compat_url,
785
822
  params=retry_params,
@@ -1636,7 +1673,7 @@ def _plugin_detection_probes() -> (
1636
1673
  def _fetch_plugin_registry() -> list[dict]:
1637
1674
  """Fetch the community integrations registry. Uses cache on failure. Never raises."""
1638
1675
  try:
1639
- resp = httpx.get(_REGISTRY_URL, timeout=10.0, headers={"User-Agent": f"nmem-cli/{__version__}"})
1676
+ resp = _httpx_get(_REGISTRY_URL, timeout=10.0, headers={"User-Agent": f"nmem-cli/{__version__}"})
1640
1677
  resp.raise_for_status()
1641
1678
  data = resp.json()
1642
1679
  # Cache for offline use
@@ -4523,8 +4560,10 @@ def cmd_threads_save(
4523
4560
  )
4524
4561
  sys.exit(1)
4525
4562
 
4526
- # Resolve project path
4527
- resolved_path = Path(project_path).resolve()
4563
+ # Use an absolute lexical path instead of resolving symlinks. Session stores
4564
+ # record the user's working directory string, not the filesystem canonical
4565
+ # realpath, so eager resolve() can drift from Claude/Codex/Gemini metadata.
4566
+ resolved_path = Path(project_path).expanduser().absolute()
4528
4567
  if not resolved_path.exists():
4529
4568
  if is_json_mode():
4530
4569
  output_json({"error": "path_not_found", "path": str(resolved_path)})
@@ -5171,7 +5210,7 @@ def cmd_sources_list(
5171
5210
  return
5172
5211
 
5173
5212
  if not sources:
5174
- console.print("[dim]No sources in library[/dim]")
5213
+ console.print("[dim]No artifacts in Library[/dim]")
5175
5214
  return
5176
5215
 
5177
5216
  table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
@@ -5219,7 +5258,7 @@ def cmd_sources_show(source_id: str, space_id: str | None = None) -> None:
5219
5258
  return
5220
5259
 
5221
5260
  console.print(f"\n[bold]{data.get('original_name', source_id)}[/bold]")
5222
- console.print(f" [dim]ID[/dim] {data.get('id', '')}")
5261
+ console.print(f" [dim]Artifact ID[/dim] {data.get('id', '')}")
5223
5262
  console.print(f" [dim]Type[/dim] {data.get('source_type', '')}")
5224
5263
  console.print(f" [dim]MIME[/dim] {data.get('mime_type', '')}")
5225
5264
  console.print(f" [dim]State[/dim] {data.get('lifecycle_state', '')}")
@@ -5275,9 +5314,9 @@ def cmd_sources_delete(
5275
5314
 
5276
5315
  if not normalized_ids:
5277
5316
  if is_json_mode():
5278
- output_json({"error": "invalid_input", "message": "No source IDs provided"})
5317
+ output_json({"error": "invalid_input", "message": "No artifact IDs provided"})
5279
5318
  else:
5280
- print_error("Invalid Input", "No source IDs provided")
5319
+ print_error("Invalid Input", "No artifact IDs provided")
5281
5320
  return
5282
5321
 
5283
5322
  if dry_run:
@@ -5316,14 +5355,14 @@ def cmd_sources_delete(
5316
5355
  else:
5317
5356
  console.print(f" [yellow]- not found[/yellow] [dim]{p['id']}[/dim]")
5318
5357
  n = len(normalized_ids)
5319
- console.print(f"\n [dim]{found} of {n} {'source' if n == 1 else 'sources'} found, would be deleted.[/dim]")
5358
+ console.print(f"\n [dim]{found} of {n} {'artifact' if n == 1 else 'artifacts'} found, would be deleted.[/dim]")
5320
5359
  return
5321
5360
 
5322
5361
  if not force and not is_json_mode():
5323
5362
  if len(normalized_ids) == 1:
5324
- prompt = f"Delete source {normalized_ids[0]}? This removes all files, chunks, and graph links."
5363
+ prompt = f"Delete artifact {normalized_ids[0]}? This removes all files, chunks, and graph links."
5325
5364
  else:
5326
- prompt = f"Delete {len(normalized_ids)} sources? This removes all files, chunks, and graph links."
5365
+ prompt = f"Delete {len(normalized_ids)} artifacts? This removes all files, chunks, and graph links."
5327
5366
  if not Confirm.ask(prompt):
5328
5367
  console.print("[dim]Cancelled[/dim]")
5329
5368
  return
@@ -5364,7 +5403,7 @@ def cmd_sources_delete(
5364
5403
  # api_delete already prints error on failure
5365
5404
  else:
5366
5405
  if failed == 0:
5367
- print_success("Deleted", f"{deleted} sources")
5406
+ print_success("Deleted", f"{deleted} artifacts")
5368
5407
  else:
5369
5408
  print_error(
5370
5409
  "Bulk delete completed with failures",
@@ -5372,6 +5411,250 @@ def cmd_sources_delete(
5372
5411
  )
5373
5412
 
5374
5413
 
5414
+ def cmd_sources_search(
5415
+ query: str,
5416
+ limit: int = 20,
5417
+ space_id: str | None = None,
5418
+ ) -> None:
5419
+ """Search across Library artifact names and summaries (FTS)."""
5420
+ params: dict[str, Any] = {"q": query, "limit": limit}
5421
+ if space_id:
5422
+ params["space_id"] = space_id
5423
+
5424
+ if not is_json_mode():
5425
+ with Progress(
5426
+ SpinnerColumn(),
5427
+ TextColumn(f"[cyan]Searching Library for '{query}'...[/cyan]"),
5428
+ console=console,
5429
+ transient=True,
5430
+ ) as p:
5431
+ p.add_task("", total=None)
5432
+ data = api_get("/sources/search", params=params, timeout=30.0)
5433
+ else:
5434
+ data = api_get("/sources/search", params=params, timeout=30.0)
5435
+
5436
+ results = data.get("results", [])
5437
+
5438
+ if is_json_mode():
5439
+ output_json(data)
5440
+ return
5441
+
5442
+ if not results:
5443
+ console.print(f"[dim]No Library artifacts matched '{query}'[/dim]")
5444
+ return
5445
+
5446
+ console.print(
5447
+ f"[green]Found {len(results)} matches[/green] for [cyan]{query}[/cyan]\n"
5448
+ )
5449
+ table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
5450
+ table.add_column("ID", style="cyan", no_wrap=True)
5451
+ table.add_column("Title")
5452
+ table.add_column("Type", style="dim")
5453
+ table.add_column("Score", justify="right", width=6)
5454
+ table.add_column("Snippet", style="dim")
5455
+ for r in results:
5456
+ table.add_row(
5457
+ r.get("source_id", ""),
5458
+ truncate(r.get("title", ""), 40),
5459
+ r.get("source_type", ""),
5460
+ format_score(r.get("score", 0.0)),
5461
+ truncate(r.get("snippet", ""), 60),
5462
+ )
5463
+ console.print(table)
5464
+
5465
+
5466
+ def cmd_sources_read(
5467
+ source_id: str,
5468
+ offset: int = 0,
5469
+ limit: int = 8000,
5470
+ space_id: str | None = None,
5471
+ ) -> None:
5472
+ """Read the parsed content of a Library artifact."""
5473
+ params: dict[str, Any] = {
5474
+ "offset": offset,
5475
+ "limit": limit,
5476
+ }
5477
+ if space_id:
5478
+ params["space_id"] = space_id
5479
+
5480
+ if not is_json_mode():
5481
+ with Progress(
5482
+ SpinnerColumn(),
5483
+ TextColumn("[cyan]Loading artifact content...[/cyan]"),
5484
+ console=console,
5485
+ transient=True,
5486
+ ) as p:
5487
+ p.add_task("", total=None)
5488
+ data = api_get(f"/sources/{source_id}/content", params=params or None, timeout=30.0)
5489
+ else:
5490
+ data = api_get(f"/sources/{source_id}/content", params=params or None, timeout=30.0)
5491
+
5492
+ window = data.get("content", "") or ""
5493
+ total_length_value = data.get("total_length")
5494
+ returned_length_value = data.get("returned_length")
5495
+ window_offset_value = data.get("offset")
5496
+ total_length = int(total_length_value) if total_length_value is not None else len(window)
5497
+ returned_length = (
5498
+ int(returned_length_value) if returned_length_value is not None else len(window)
5499
+ )
5500
+ window_offset = int(window_offset_value) if window_offset_value is not None else offset
5501
+ window_end = window_offset + returned_length
5502
+ has_more = bool(data.get("has_more"))
5503
+
5504
+ if is_json_mode():
5505
+ output_json({
5506
+ "source_id": source_id,
5507
+ "original_name": data.get("original_name", ""),
5508
+ "offset": window_offset,
5509
+ "returned_length": returned_length,
5510
+ "total_length": total_length,
5511
+ "has_more": has_more,
5512
+ "content": window,
5513
+ })
5514
+ return
5515
+
5516
+ console.print(f"\n[bold]{data.get('original_name', source_id)}[/bold]")
5517
+ console.print(f" [dim]Artifact ID[/dim] {source_id}")
5518
+ console.print(
5519
+ f" [dim]Bytes[/dim] {window_offset}..{window_end} of {total_length}"
5520
+ )
5521
+ console.print()
5522
+ console.print(window)
5523
+ if has_more:
5524
+ console.print(
5525
+ f"\n[dim]... {total_length - window_end} more characters. Use --offset {window_end} to continue.[/dim]"
5526
+ )
5527
+
5528
+
5529
+ def cmd_sources_search_chunks(
5530
+ source_id: str,
5531
+ query: str,
5532
+ limit: int = 5,
5533
+ space_id: str | None = None,
5534
+ ) -> None:
5535
+ """Search within a single artifact's indexed chunks (BM25)."""
5536
+ arguments: dict[str, Any] = {
5537
+ "source_id": source_id,
5538
+ "query": query,
5539
+ "limit": limit,
5540
+ }
5541
+ if space_id:
5542
+ arguments["space_id"] = space_id
5543
+
5544
+ if not is_json_mode():
5545
+ with Progress(
5546
+ SpinnerColumn(),
5547
+ TextColumn(f"[cyan]Searching artifact sections for '{query}'...[/cyan]"),
5548
+ console=console,
5549
+ transient=True,
5550
+ ) as p:
5551
+ p.add_task("", total=None)
5552
+ data = api_mcp_tool_call("search_source_chunks", arguments)
5553
+ else:
5554
+ data = api_mcp_tool_call("search_source_chunks", arguments)
5555
+
5556
+ if is_json_mode():
5557
+ output_json(data)
5558
+ return
5559
+
5560
+ if data.get("status") == "error":
5561
+ print_error("Search failed", data.get("error", "Unknown error"))
5562
+ return
5563
+
5564
+ chunks = data.get("chunks", [])
5565
+ source_name = data.get("source_name") or source_id
5566
+ if not chunks:
5567
+ console.print(f"[dim]No chunks matched '{query}' in {source_name}[/dim]")
5568
+ return
5569
+
5570
+ console.print(
5571
+ f"[green]Matched {len(chunks)}[/green] of {data.get('total_chunks', '?')} chunks "
5572
+ f"in [bold]{source_name}[/bold]\n"
5573
+ )
5574
+ for i, chunk in enumerate(chunks, 1):
5575
+ idx = chunk.get("chunk_index")
5576
+ score = chunk.get("score")
5577
+ score_str = f" [dim]score={format_score(score)}[/dim]" if score is not None else ""
5578
+ idx_str = f"chunk {idx}" if idx is not None else f"match {i}"
5579
+ console.print(f"[bold cyan]{idx_str}[/bold cyan]{score_str}")
5580
+ text = chunk.get("text", "")
5581
+ preview = text if len(text) < 800 else text[:800] + "..."
5582
+ console.print(preview)
5583
+ console.print()
5584
+
5585
+
5586
+ def cmd_sources_analyze(
5587
+ source_id: str,
5588
+ columns: list[str] | None = None,
5589
+ space_id: str | None = None,
5590
+ ) -> None:
5591
+ """Analyze a tabular Library artifact (CSV/TSV/XLSX/XLS)."""
5592
+ arguments: dict[str, Any] = {"source_id": source_id}
5593
+ if columns:
5594
+ arguments["columns"] = columns
5595
+ if space_id:
5596
+ arguments["space_id"] = space_id
5597
+
5598
+ if not is_json_mode():
5599
+ with Progress(
5600
+ SpinnerColumn(),
5601
+ TextColumn("[cyan]Analyzing artifact...[/cyan]"),
5602
+ console=console,
5603
+ transient=True,
5604
+ ) as p:
5605
+ p.add_task("", total=None)
5606
+ data = api_mcp_tool_call("analyze_source_data", arguments)
5607
+ else:
5608
+ data = api_mcp_tool_call("analyze_source_data", arguments)
5609
+
5610
+ if is_json_mode():
5611
+ output_json(data)
5612
+ return
5613
+
5614
+ if data.get("status") == "error":
5615
+ print_error("Analysis failed", data.get("error", "Unknown error"))
5616
+ return
5617
+
5618
+ source_name = data.get("source_name") or source_id
5619
+ console.print(f"\n[bold]{source_name}[/bold]")
5620
+ sheets = data.get("sheets") or ([data] if data.get("columns") else [])
5621
+ if not sheets:
5622
+ console.print("[dim]Analyzer returned no sheet data[/dim]")
5623
+ console.print()
5624
+ console.print_json(data=data)
5625
+ return
5626
+
5627
+ for sheet in sheets:
5628
+ sheet_name = sheet.get("sheet_name") or sheet.get("name")
5629
+ if sheet_name:
5630
+ console.print(f"\n[bold]Sheet:[/bold] {sheet_name}")
5631
+ rows = sheet.get("row_count") or sheet.get("rows")
5632
+ cols_meta = sheet.get("columns") or []
5633
+ if rows is not None:
5634
+ console.print(f" [dim]Rows[/dim] {rows}")
5635
+ if cols_meta:
5636
+ console.print(f" [dim]Columns[/dim] {len(cols_meta)}")
5637
+ table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
5638
+ table.add_column("Column", style="cyan")
5639
+ table.add_column("Type", style="dim")
5640
+ table.add_column("Non-null", justify="right")
5641
+ table.add_column("Summary", style="dim")
5642
+ for col in cols_meta:
5643
+ name = col.get("name", "")
5644
+ dtype = col.get("dtype", col.get("type", ""))
5645
+ non_null = col.get("non_null_count", col.get("count", ""))
5646
+ summary_bits: list[str] = []
5647
+ if col.get("min") is not None and col.get("max") is not None:
5648
+ summary_bits.append(f"min={col['min']} max={col['max']}")
5649
+ if col.get("mean") is not None:
5650
+ summary_bits.append(f"mean={col['mean']:.2f}")
5651
+ if col.get("top"):
5652
+ summary_bits.append(f"top={col['top']}")
5653
+ table.add_row(name, str(dtype), str(non_null), ", ".join(summary_bits))
5654
+ console.print(table)
5655
+ console.print()
5656
+
5657
+
5375
5658
  def cmd_sources_add(
5376
5659
  targets: list[str],
5377
5660
  comment: str | None = None,
@@ -5572,7 +5855,7 @@ def _get_remote_access_status() -> dict[str, Any] | None:
5572
5855
  """Fetch Access Anywhere status from the local backend (GET)."""
5573
5856
  for base_url in _iter_local_management_base_urls():
5574
5857
  try:
5575
- response = httpx.get(
5858
+ response = _httpx_get(
5576
5859
  f"{base_url}/api/remote-access/status", timeout=10.0
5577
5860
  )
5578
5861
  except httpx.RequestError:
@@ -6411,7 +6694,7 @@ def _check_for_update(platform_id: str) -> Optional[dict]:
6411
6694
  """Call backbone API to check for updates. Returns parsed JSON or None."""
6412
6695
  url = f"{_UPDATE_CHECK_URL}?platform={platform_id}&secure=false"
6413
6696
  try:
6414
- resp = httpx.get(
6697
+ resp = _httpx_get(
6415
6698
  url,
6416
6699
  headers={"Accept": "application/json", "User-Agent": _UPDATE_USER_AGENT},
6417
6700
  timeout=30.0,
@@ -6922,7 +7205,7 @@ def _update_appimage(yes: bool) -> None:
6922
7205
  ) as p:
6923
7206
  p.add_task("", total=None)
6924
7207
  try:
6925
- resp = httpx.get(
7208
+ resp = _httpx_get(
6926
7209
  f"{_UPDATE_CHECK_URL}?platform=linux-appimage&secure=true",
6927
7210
  headers={"Accept": "application/json", "User-Agent": _UPDATE_USER_AGENT},
6928
7211
  timeout=30.0,
@@ -7008,7 +7291,7 @@ def _update_appimage(yes: bool) -> None:
7008
7291
  suffix=".AppImage", dir=str(appimage_path.parent), delete=False,
7009
7292
  ) as tmp:
7010
7293
  temp_path = Path(tmp.name)
7011
- with httpx.stream(
7294
+ with _httpx_stream(
7012
7295
  "GET", download_url, timeout=600.0, follow_redirects=True,
7013
7296
  ) as stream:
7014
7297
  stream.raise_for_status()
@@ -8480,9 +8763,9 @@ EXAMPLES
8480
8763
  nmem wm patch --heading "## Notes" --append "extra note" Append to a section
8481
8764
  nmem wm history List past WM dates
8482
8765
  nmem --json m search "x" JSON output
8483
- nmem s List sources in library
8484
- nmem s show <id> Show source details
8485
- nmem s delete <id> Delete a source
8766
+ nmem library List Library artifacts
8767
+ nmem library show <id> Show artifact details
8768
+ nmem library delete <id> Delete an artifact
8486
8769
  nmem g expand <id> Graph connections for a memory
8487
8770
  nmem f Activity feed (last 7 days)
8488
8771
  nmem f --days 1 Today's activity
@@ -8493,7 +8776,8 @@ ALIASES
8493
8776
  m = memories
8494
8777
  t = threads
8495
8778
  wm = working-memory
8496
- s = sources
8779
+ l = library
8780
+ s = sources (legacy alias)
8497
8781
  g = graph
8498
8782
  f = feed
8499
8783
  c = communities
@@ -9506,25 +9790,31 @@ PRIORITY
9506
9790
  spaces_subs.add_parser("enable", help="Turn on memory spaces")
9507
9791
  spaces_subs.add_parser("disable", help="Turn off memory spaces (only when no extra spaces remain)")
9508
9792
 
9509
- # sources (with alias 's')
9510
- for name in ["sources", "s"]:
9793
+ # library artifacts (legacy aliases: sources, s)
9794
+ for name, help_text in [
9795
+ ("library", "Library — files, web pages, notes, and other artifacts"),
9796
+ ("lib", argparse.SUPPRESS),
9797
+ ("l", argparse.SUPPRESS),
9798
+ ("sources", argparse.SUPPRESS),
9799
+ ("s", argparse.SUPPRESS),
9800
+ ]:
9511
9801
  src_parser = subparsers.add_parser(
9512
- name, help="Source library — files, URLs, documents", parents=[_ambient_space_parent]
9802
+ name, help=help_text, parents=[_ambient_space_parent]
9513
9803
  )
9514
9804
  src_subs = src_parser.add_subparsers(dest="action")
9515
9805
 
9516
9806
  ls = src_subs.add_parser(
9517
9807
  "list",
9518
- help="List sources (default)",
9808
+ help="List Library artifacts (default)",
9519
9809
  parents=[_space_parent],
9520
9810
  epilog="""examples:
9521
- nmem s
9522
- nmem s list --type url -n 50
9523
- nmem s list --state error""",
9811
+ nmem library
9812
+ nmem library list --type url -n 50
9813
+ nmem library list --state error""",
9524
9814
  formatter_class=argparse.RawDescriptionHelpFormatter,
9525
9815
  )
9526
9816
  ls.add_argument(
9527
- "-n", "--limit", type=int, default=20, help="Max sources to show"
9817
+ "-n", "--limit", type=int, default=20, help="Max artifacts to show"
9528
9818
  )
9529
9819
  ls.add_argument(
9530
9820
  "--type", dest="source_type",
@@ -9548,7 +9838,7 @@ PRIORITY
9548
9838
  add.add_argument("target", nargs="+", help="File path(s) or URL(s) to ingest")
9549
9839
  add.add_argument(
9550
9840
  "-c", "--comment", dest="user_comment",
9551
- help="Comment about the source",
9841
+ help="Comment about the artifact",
9552
9842
  )
9553
9843
  add.add_argument(
9554
9844
  "-l", "--label", dest="labels", action="append",
@@ -9557,26 +9847,26 @@ PRIORITY
9557
9847
 
9558
9848
  sh = src_subs.add_parser(
9559
9849
  "show",
9560
- help="Show source details",
9850
+ help="Show artifact details",
9561
9851
  parents=[_space_parent],
9562
9852
  epilog="""examples:
9563
- nmem s show src-abc123
9564
- nmem --json s show src-abc123""",
9853
+ nmem library show src-abc123
9854
+ nmem --json library show src-abc123""",
9565
9855
  formatter_class=argparse.RawDescriptionHelpFormatter,
9566
9856
  )
9567
- sh.add_argument("id", help="Source ID")
9857
+ sh.add_argument("id", help="Artifact ID")
9568
9858
 
9569
9859
  d = src_subs.add_parser(
9570
9860
  "delete",
9571
- help="Delete source(s)",
9861
+ help="Delete artifact(s)",
9572
9862
  parents=[_space_parent],
9573
9863
  epilog="""examples:
9574
- nmem s delete src-abc123
9575
- nmem s delete src-abc123 src-def456 --force
9576
- nmem s delete src-abc123 --dry-run""",
9864
+ nmem library delete src-abc123
9865
+ nmem library delete src-abc123 src-def456 --force
9866
+ nmem library delete src-abc123 --dry-run""",
9577
9867
  formatter_class=argparse.RawDescriptionHelpFormatter,
9578
9868
  )
9579
- d.add_argument("id", nargs="+", help="Source ID(s) to delete")
9869
+ d.add_argument("id", nargs="+", help="Artifact ID(s) to delete")
9580
9870
  d.add_argument(
9581
9871
  "-f", "--force", action="store_true",
9582
9872
  help="Skip confirmation prompt",
@@ -9586,6 +9876,69 @@ PRIORITY
9586
9876
  help="Preview what would be deleted without making changes",
9587
9877
  )
9588
9878
 
9879
+ srch = src_subs.add_parser(
9880
+ "search",
9881
+ help="Search Library artifacts by name/summary (FTS)",
9882
+ parents=[_space_parent],
9883
+ epilog="""examples:
9884
+ nmem library search "meeting notes"
9885
+ nmem library search roadmap -n 10""",
9886
+ formatter_class=argparse.RawDescriptionHelpFormatter,
9887
+ )
9888
+ srch.add_argument("query", help="Search query")
9889
+ srch.add_argument(
9890
+ "-n", "--limit", type=int, default=20, help="Max results (default 20)"
9891
+ )
9892
+
9893
+ rd = src_subs.add_parser(
9894
+ "read",
9895
+ help="Read parsed artifact content",
9896
+ parents=[_space_parent],
9897
+ epilog="""examples:
9898
+ nmem library read src-abc123
9899
+ nmem library read src-abc123 --offset 8000 --limit 8000""",
9900
+ formatter_class=argparse.RawDescriptionHelpFormatter,
9901
+ )
9902
+ rd.add_argument("id", help="Artifact ID")
9903
+ rd.add_argument(
9904
+ "--offset", type=int, default=0, help="Character offset (default 0)"
9905
+ )
9906
+ rd.add_argument(
9907
+ "--limit", type=int, default=8000,
9908
+ help="Max characters to return (default 8000)",
9909
+ )
9910
+
9911
+ sc = src_subs.add_parser(
9912
+ "search-chunks",
9913
+ help="Search within an artifact's indexed chunks",
9914
+ parents=[_space_parent],
9915
+ epilog="""examples:
9916
+ nmem library search-chunks src-abc123 "risk factors"
9917
+ nmem library search-chunks src-abc123 budget -n 3""",
9918
+ formatter_class=argparse.RawDescriptionHelpFormatter,
9919
+ )
9920
+ sc.add_argument("id", help="Artifact ID")
9921
+ sc.add_argument("query", help="Search query")
9922
+ sc.add_argument(
9923
+ "-n", "--limit", type=int, default=5,
9924
+ help="Max matching chunks (1-20, default 5)",
9925
+ )
9926
+
9927
+ an = src_subs.add_parser(
9928
+ "analyze",
9929
+ help="Analyze tabular artifact (CSV/TSV/XLSX/XLS)",
9930
+ parents=[_space_parent],
9931
+ epilog="""examples:
9932
+ nmem library analyze src-abc123
9933
+ nmem library analyze src-abc123 --column price --column region""",
9934
+ formatter_class=argparse.RawDescriptionHelpFormatter,
9935
+ )
9936
+ an.add_argument("id", help="Artifact ID")
9937
+ an.add_argument(
9938
+ "--column", dest="columns", action="append",
9939
+ help="Column name to analyze (repeatable)",
9940
+ )
9941
+
9589
9942
  # license
9590
9943
  lic_parser = subparsers.add_parser("license", help="License management")
9591
9944
  lic_subs = lic_parser.add_subparsers(dest="action")
@@ -10213,7 +10566,7 @@ def main() -> int:
10213
10566
  cmd_spaces_set_enabled(False)
10214
10567
  else:
10215
10568
  cmd_spaces_list()
10216
- elif cmd in ("sources", "s"):
10569
+ elif cmd in ("library", "lib", "l", "sources", "s"):
10217
10570
  action = args.action
10218
10571
  if action == "add":
10219
10572
  cmd_sources_add(
@@ -10226,6 +10579,32 @@ def main() -> int:
10226
10579
  cmd_sources_show(args.id, getattr(args, "space", None))
10227
10580
  elif action == "delete":
10228
10581
  cmd_sources_delete(args.id, getattr(args, "force", False), getattr(args, "dry_run", False), getattr(args, "space", None))
10582
+ elif action == "search":
10583
+ cmd_sources_search(
10584
+ query=args.query,
10585
+ limit=getattr(args, "limit", 20),
10586
+ space_id=getattr(args, "space", None),
10587
+ )
10588
+ elif action == "read":
10589
+ cmd_sources_read(
10590
+ source_id=args.id,
10591
+ offset=getattr(args, "offset", 0),
10592
+ limit=getattr(args, "limit", 8000),
10593
+ space_id=getattr(args, "space", None),
10594
+ )
10595
+ elif action == "search-chunks":
10596
+ cmd_sources_search_chunks(
10597
+ source_id=args.id,
10598
+ query=args.query,
10599
+ limit=getattr(args, "limit", 5),
10600
+ space_id=getattr(args, "space", None),
10601
+ )
10602
+ elif action == "analyze":
10603
+ cmd_sources_analyze(
10604
+ source_id=args.id,
10605
+ columns=getattr(args, "columns", None),
10606
+ space_id=getattr(args, "space", None),
10607
+ )
10229
10608
  else:
10230
10609
  # Default: list
10231
10610
  cmd_sources_list(
@@ -29,6 +29,30 @@ from typing import Any, Optional
29
29
  logger = logging.getLogger(__name__)
30
30
 
31
31
 
32
+ def _project_path_variants(project_path: str) -> list[str]:
33
+ variants: list[str] = []
34
+
35
+ def _add(candidate: str | None) -> None:
36
+ if not candidate:
37
+ return
38
+ normalized = os.path.normpath(candidate)
39
+ if normalized not in variants:
40
+ variants.append(normalized)
41
+
42
+ expanded = os.path.expanduser(project_path)
43
+ _add(expanded)
44
+ _add(os.path.abspath(expanded))
45
+ _add(os.path.realpath(expanded))
46
+
47
+ for existing in list(variants):
48
+ if existing.startswith("/private/"):
49
+ _add(existing[len("/private"):])
50
+ elif existing.startswith(("/var/", "/tmp/", "/etc/")):
51
+ _add("/private" + existing)
52
+
53
+ return variants
54
+
55
+
32
56
  def _env_path(env_var: str, default: Path) -> Path:
33
57
  raw = os.environ.get(env_var, "").strip()
34
58
  if not raw:
@@ -47,33 +71,45 @@ def _get_codex_sessions_dir() -> Path:
47
71
  def _encode_claude_project_path(project_path: str) -> str:
48
72
  return _encode_claude_project_path_variant(
49
73
  project_path,
50
- preserve_hidden_dot=False,
74
+ dot_mode="all_as_hyphen",
75
+ )
76
+
77
+
78
+ def _encode_claude_project_path_hidden_dot_compat(project_path: str) -> str:
79
+ return _encode_claude_project_path_variant(
80
+ project_path,
81
+ dot_mode="hidden_only",
51
82
  )
52
83
 
53
84
 
54
85
  def _encode_claude_project_path_legacy(project_path: str) -> str:
55
86
  return _encode_claude_project_path_variant(
56
87
  project_path,
57
- preserve_hidden_dot=True,
88
+ dot_mode="preserve",
58
89
  )
59
90
 
60
91
 
61
92
  def _resolve_claude_project_dir(project_path: str) -> tuple[Optional[Path], Path]:
62
93
  claude_projects_dir = _get_claude_projects_dir()
94
+ project_path_variants = _project_path_variants(project_path)
63
95
  candidate_dirs: list[Path] = []
64
- for encoded in (
65
- _encode_claude_project_path(project_path),
66
- _encode_claude_project_path_legacy(project_path),
67
- ):
68
- candidate = claude_projects_dir / encoded
69
- if candidate not in candidate_dirs:
70
- candidate_dirs.append(candidate)
96
+ for path_variant in project_path_variants:
97
+ for encoded in (
98
+ _encode_claude_project_path(path_variant),
99
+ _encode_claude_project_path_hidden_dot_compat(path_variant),
100
+ _encode_claude_project_path_legacy(path_variant),
101
+ ):
102
+ candidate = claude_projects_dir / encoded
103
+ if candidate not in candidate_dirs:
104
+ candidate_dirs.append(candidate)
71
105
 
72
106
  for candidate in candidate_dirs:
73
107
  if candidate.exists() and candidate.is_dir():
74
108
  return candidate, candidate_dirs[0]
75
109
 
76
- normalized_project_path = _normalize_project_path(project_path)
110
+ normalized_project_paths = {
111
+ _normalize_project_path(path_variant) for path_variant in project_path_variants
112
+ }
77
113
  if claude_projects_dir.exists() and claude_projects_dir.is_dir():
78
114
  for project_dir in claude_projects_dir.iterdir():
79
115
  if not project_dir.is_dir():
@@ -82,7 +118,7 @@ def _resolve_claude_project_dir(project_path: str) -> tuple[Optional[Path], Path
82
118
  decoded_path = _decode_claude_project_dir_name(project_dir.name)
83
119
  if (
84
120
  decoded_path is not None
85
- and _normalize_project_path(decoded_path) == normalized_project_path
121
+ and _normalize_project_path(decoded_path) in normalized_project_paths
86
122
  ):
87
123
  return project_dir, candidate_dirs[0]
88
124
 
@@ -1391,10 +1427,11 @@ def _find_valid_path(base: str, remaining: list[str]) -> Optional[str]:
1391
1427
  return result
1392
1428
 
1393
1429
  if len(remaining) >= 2:
1394
- combined = remaining[0] + "-" + remaining[1]
1395
- result = _find_valid_path(base, [combined] + remaining[2:])
1396
- if result:
1397
- return result
1430
+ for delimiter in ("-", "."):
1431
+ combined = remaining[0] + delimiter + remaining[1]
1432
+ result = _find_valid_path(base, [combined] + remaining[2:])
1433
+ if result:
1434
+ return result
1398
1435
 
1399
1436
  return None
1400
1437
 
@@ -1441,16 +1478,17 @@ def _find_valid_path_windows(base: str, remaining: list[str]) -> Optional[str]:
1441
1478
  return result
1442
1479
 
1443
1480
  if len(remaining) >= 2:
1444
- combined = remaining[0] + "-" + remaining[1]
1445
- result = _find_valid_path_windows(base, [combined] + remaining[2:])
1446
- if result:
1447
- return result
1481
+ for delimiter in ("-", "."):
1482
+ combined = remaining[0] + delimiter + remaining[1]
1483
+ result = _find_valid_path_windows(base, [combined] + remaining[2:])
1484
+ if result:
1485
+ return result
1448
1486
 
1449
1487
  return None
1450
1488
 
1451
1489
 
1452
1490
  def _encode_claude_project_path_variant(
1453
- project_path: str, *, preserve_hidden_dot: bool
1491
+ project_path: str, *, dot_mode: str
1454
1492
  ) -> str:
1455
1493
  windows_match = re.match(r"^([A-Za-z]):[\\/](.*)$", project_path)
1456
1494
  if windows_match:
@@ -1459,7 +1497,7 @@ def _encode_claude_project_path_variant(
1459
1497
  segments = [
1460
1498
  _encode_claude_path_segment(
1461
1499
  segment,
1462
- preserve_hidden_dot=preserve_hidden_dot,
1500
+ dot_mode=dot_mode,
1463
1501
  )
1464
1502
  for segment in re.split(r"[\\/]+", rest)
1465
1503
  if segment
@@ -1467,7 +1505,7 @@ def _encode_claude_project_path_variant(
1467
1505
  return "-".join([drive_letter, *segments]) if segments else drive_letter
1468
1506
 
1469
1507
  segments = [
1470
- _encode_claude_path_segment(segment, preserve_hidden_dot=preserve_hidden_dot)
1508
+ _encode_claude_path_segment(segment, dot_mode=dot_mode)
1471
1509
  for segment in project_path.replace("\\", "/").split("/")
1472
1510
  if segment
1473
1511
  ]
@@ -1475,9 +1513,11 @@ def _encode_claude_project_path_variant(
1475
1513
 
1476
1514
 
1477
1515
  def _encode_claude_path_segment(
1478
- segment: str, *, preserve_hidden_dot: bool
1516
+ segment: str, *, dot_mode: str
1479
1517
  ) -> str:
1480
- if not preserve_hidden_dot and segment.startswith(".") and len(segment) > 1:
1518
+ if dot_mode == "all_as_hyphen":
1519
+ return segment.replace(".", "-")
1520
+ if dot_mode == "hidden_only" and segment.startswith(".") and len(segment) > 1:
1481
1521
  return "-" + segment[1:]
1482
1522
  return segment
1483
1523
 
@@ -79,6 +79,7 @@ class ApiClient:
79
79
  timeout=30.0,
80
80
  headers=self._headers,
81
81
  cookies=self._cookies,
82
+ trust_env=self.is_remote,
82
83
  )
83
84
  return self._client
84
85
 
nmem_cli-0.7.6/.gitignore DELETED
@@ -1,5 +0,0 @@
1
- config.env
2
-
3
- # Generated agent prompts — source of truth is embedded_prompts.py
4
- src/nowledge_graph_server/agent/feed_agent_system.md
5
- src/nowledge_graph_server/agent/system_prompt.md
File without changes
File without changes