nmem-cli 0.7.0__tar.gz → 0.7.6__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 (23) hide show
  1. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/PKG-INFO +1 -1
  2. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/pyproject.toml +1 -1
  3. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/__init__.py +1 -1
  4. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/cli.py +810 -40
  5. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/api_client.py +55 -10
  6. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/app.py +2 -0
  7. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/.gitignore +0 -0
  8. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/README.md +0 -0
  9. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/license_payload.py +0 -0
  10. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/py.typed +0 -0
  11. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/session_import.py +0 -0
  12. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/__init__.py +0 -0
  13. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/__main__.py +0 -0
  14. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/__init__.py +0 -0
  15. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/dashboard.py +0 -0
  16. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/graph.py +0 -0
  17. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/help.py +0 -0
  18. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/memories.py +0 -0
  19. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/memory_detail.py +0 -0
  20. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/settings.py +0 -0
  21. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/thread_detail.py +0 -0
  22. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/threads.py +0 -0
  23. {nmem_cli-0.7.0 → nmem_cli-0.7.6}/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.7.0
3
+ Version: 0.7.6
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.0"
3
+ version = "0.7.6"
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.0"
23
+ __version__ = "0.7.6"
24
24
  __author__ = "Nowledge Labs"
25
25
 
26
26
  from .cli import main
@@ -13,6 +13,7 @@ import ipaddress
13
13
  import json as json_module
14
14
  import os
15
15
  import platform
16
+ import re
16
17
  import shlex
17
18
  import shutil
18
19
  import subprocess
@@ -1369,6 +1370,47 @@ def _status_version_warning(
1369
1370
  )
1370
1371
 
1371
1372
 
1373
+ def _summarize_search_index_status(model_status: dict[str, Any]) -> dict[str, Any]:
1374
+ """Normalize search-index action state for CLI and headless users."""
1375
+ metadata_update_needed = bool(
1376
+ model_status.get("index_metadata_update_needed")
1377
+ or model_status.get("projection_repair_needed")
1378
+ )
1379
+ metadata_update_reason = str(
1380
+ model_status.get("index_metadata_update_reason")
1381
+ or model_status.get("projection_repair_reason")
1382
+ or ""
1383
+ )
1384
+ reindex_needed = bool(model_status.get("reindex_needed"))
1385
+ reindex_reason = str(model_status.get("reindex_reason") or "")
1386
+ available = bool(model_status.get("available_for_indexing"))
1387
+
1388
+ if reindex_needed:
1389
+ return {
1390
+ "status": "rebuild_needed",
1391
+ "action_required": True,
1392
+ "message": reindex_reason or "Search index must be rebuilt.",
1393
+ }
1394
+ if metadata_update_needed:
1395
+ return {
1396
+ "status": "updating_metadata",
1397
+ "action_required": False,
1398
+ "message": metadata_update_reason
1399
+ or "Search metadata is being updated in the background.",
1400
+ }
1401
+ if available:
1402
+ return {
1403
+ "status": "ready",
1404
+ "action_required": False,
1405
+ "message": "Search index is ready.",
1406
+ }
1407
+ return {
1408
+ "status": "model_missing",
1409
+ "action_required": False,
1410
+ "message": str(model_status.get("message") or "Search model is not available."),
1411
+ }
1412
+
1413
+
1372
1414
  def cmd_status() -> None:
1373
1415
  if not is_json_mode():
1374
1416
  with Progress(
@@ -1402,6 +1444,12 @@ def cmd_status() -> None:
1402
1444
  except Exception:
1403
1445
  pass
1404
1446
 
1447
+ search_index: dict[str, Any] | None = None
1448
+ try:
1449
+ search_index = _summarize_search_index_status(api_get("/models/bge-m3/status"))
1450
+ except Exception:
1451
+ search_index = None
1452
+
1405
1453
  if is_json_mode():
1406
1454
  if agent_data:
1407
1455
  result["agent"] = {
@@ -1415,6 +1463,8 @@ def cmd_status() -> None:
1415
1463
  "usage_unreported_this_hour": agent_data.get("usage_unreported_this_hour", 0),
1416
1464
  "usage_unreported_today": agent_data.get("usage_unreported_today", 0),
1417
1465
  }
1466
+ if search_index:
1467
+ result["search_index"] = search_index
1418
1468
  output_json(result)
1419
1469
  else:
1420
1470
  status_color = "green" if result["status"] == "ok" else "yellow"
@@ -1435,6 +1485,19 @@ def cmd_status() -> None:
1435
1485
  console.print(
1436
1486
  f" database {'connected' if result['database'] else 'disconnected'}"
1437
1487
  )
1488
+ if search_index:
1489
+ search_state = search_index["status"]
1490
+ if search_state == "rebuild_needed":
1491
+ state_label = "[yellow]rebuild needed[/yellow]"
1492
+ elif search_state == "updating_metadata":
1493
+ state_label = "[cyan]updating metadata[/cyan]"
1494
+ elif search_state == "ready":
1495
+ state_label = "[green]ready[/green]"
1496
+ else:
1497
+ state_label = "[dim]model missing[/dim]"
1498
+ console.print(f" search {state_label}")
1499
+ if search_index.get("message"):
1500
+ console.print(f" [dim]{search_index['message']}[/dim]")
1438
1501
  version_warning = _status_version_warning(
1439
1502
  cli_version=result["cli_version"],
1440
1503
  server_version=result["server_version"],
@@ -2993,20 +3056,224 @@ def cmd_memories_update(
2993
3056
  console.print(f" [dim]fields: {', '.join(payload.keys())}[/dim]")
2994
3057
 
2995
3058
 
2996
- def cmd_memories_delete(
2997
- memory_ids: list[str],
2998
- force: bool = False,
2999
- dry_run: bool = False,
3000
- space_id: str | None = None,
3001
- ) -> None:
3059
+ def _normalize_cli_ids(raw_ids: list[str] | None) -> list[str]:
3002
3060
  normalized_ids: list[str] = []
3003
3061
  seen: set[str] = set()
3004
- for memory_id in memory_ids:
3005
- clean_id = memory_id.strip()
3062
+ for raw_id in raw_ids or []:
3063
+ clean_id = str(raw_id).strip()
3006
3064
  if not clean_id or clean_id in seen:
3007
3065
  continue
3008
3066
  normalized_ids.append(clean_id)
3009
3067
  seen.add(clean_id)
3068
+ return normalized_ids
3069
+
3070
+
3071
+ def _selection_source_space(
3072
+ source_space_id: str | None,
3073
+ all_in_space: str | None,
3074
+ ) -> str | None:
3075
+ if source_space_id and all_in_space and source_space_id != all_in_space:
3076
+ if is_json_mode():
3077
+ output_json(
3078
+ {
3079
+ "error": "conflicting_space",
3080
+ "message": (
3081
+ "--space and --all-in-space refer to different source spaces."
3082
+ ),
3083
+ }
3084
+ )
3085
+ else:
3086
+ print_error(
3087
+ "Conflicting Spaces",
3088
+ "--space and --all-in-space must match when both are provided.",
3089
+ "Use --space as the source guard for explicit IDs, or "
3090
+ "--all-in-space for whole-space moves.",
3091
+ )
3092
+ return _PREVIEW_ERROR
3093
+ return all_in_space or source_space_id
3094
+
3095
+
3096
+ def _memory_move_selection_payload(
3097
+ memory_ids: list[str],
3098
+ *,
3099
+ source_space_id: str | None,
3100
+ all_in_space: str | None,
3101
+ exclude_memory_ids: list[str] | None,
3102
+ ) -> dict[str, Any] | None:
3103
+ normalized_ids = _normalize_cli_ids(memory_ids)
3104
+ source_space = _selection_source_space(source_space_id, all_in_space)
3105
+ if source_space == _PREVIEW_ERROR:
3106
+ return None
3107
+
3108
+ if all_in_space and normalized_ids:
3109
+ if is_json_mode():
3110
+ output_json(
3111
+ {
3112
+ "error": "conflicting_selection",
3113
+ "message": "Pass memory IDs or --all-in-space, not both.",
3114
+ }
3115
+ )
3116
+ else:
3117
+ print_error(
3118
+ "Conflicting Selection",
3119
+ "Pass memory IDs or --all-in-space, not both.",
3120
+ )
3121
+ return None
3122
+
3123
+ if all_in_space:
3124
+ return {
3125
+ "mode": "space",
3126
+ "source_space_id": source_space,
3127
+ "exclude_memory_ids": _normalize_cli_ids(exclude_memory_ids),
3128
+ }
3129
+
3130
+ if not normalized_ids:
3131
+ if is_json_mode():
3132
+ output_json(
3133
+ {
3134
+ "error": "invalid_input",
3135
+ "message": "Provide memory IDs or --all-in-space.",
3136
+ }
3137
+ )
3138
+ else:
3139
+ print_error(
3140
+ "Invalid Input",
3141
+ "Provide memory IDs or --all-in-space.",
3142
+ "Example: nmem m move mem-1 mem-2 "
3143
+ "--space Research --to-space Archive",
3144
+ )
3145
+ return None
3146
+
3147
+ payload: dict[str, Any] = {
3148
+ "mode": "ids",
3149
+ "memory_ids": normalized_ids,
3150
+ }
3151
+ if source_space:
3152
+ payload["source_space_id"] = source_space
3153
+ return payload
3154
+
3155
+
3156
+ def _display_space_name(
3157
+ preview_or_result: dict[str, Any],
3158
+ key: str,
3159
+ fallback: str | None = None,
3160
+ ) -> str:
3161
+ fallback_name = str(fallback or "").strip()
3162
+ if fallback_name:
3163
+ return fallback_name
3164
+ value = str(preview_or_result.get(key) or "").strip()
3165
+ return value or "Default"
3166
+
3167
+
3168
+ def cmd_memories_move(
3169
+ memory_ids: list[str],
3170
+ *,
3171
+ target_space_id: str,
3172
+ source_space_id: str | None = None,
3173
+ all_in_space: str | None = None,
3174
+ exclude_memory_ids: list[str] | None = None,
3175
+ force: bool = False,
3176
+ dry_run: bool = False,
3177
+ ) -> None:
3178
+ """Move explicit memories, or every memory in one source space, to another."""
3179
+ target_space = str(target_space_id or "").strip()
3180
+ if not target_space:
3181
+ if is_json_mode():
3182
+ output_json(
3183
+ {"error": "missing_target_space", "message": "Pass --to-space."}
3184
+ )
3185
+ else:
3186
+ print_error("Missing Target Space", "Pass --to-space <space name>.")
3187
+ return
3188
+
3189
+ selection = _memory_move_selection_payload(
3190
+ memory_ids,
3191
+ source_space_id=source_space_id,
3192
+ all_in_space=all_in_space,
3193
+ exclude_memory_ids=exclude_memory_ids,
3194
+ )
3195
+ if selection is None:
3196
+ return
3197
+
3198
+ request = {"selection": selection, "target_space_id": target_space}
3199
+ preview = api_post("/memories/bulk/move/preview", request)
3200
+ display_source_space = _display_space_name(
3201
+ preview,
3202
+ "source_space_id",
3203
+ selection.get("source_space_id"),
3204
+ )
3205
+ display_target_space = _display_space_name(
3206
+ preview,
3207
+ "target_space_id",
3208
+ target_space,
3209
+ )
3210
+ if dry_run:
3211
+ if is_json_mode():
3212
+ output_json({"dry_run": True, **preview})
3213
+ else:
3214
+ console.print("[bold]Dry run[/bold] — no changes will be made.")
3215
+ console.print(
3216
+ f" [cyan]{preview.get('count', 0)}[/cyan] memories would move "
3217
+ f"from [cyan]{display_source_space}[/cyan] "
3218
+ f"to [cyan]{display_target_space}[/cyan]."
3219
+ )
3220
+ if preview.get("limit_exceeded"):
3221
+ console.print(f" [yellow]{preview.get('message')}[/yellow]")
3222
+ return
3223
+
3224
+ if not force and not is_json_mode():
3225
+ count = int(preview.get("count", 0) or 0)
3226
+ if preview.get("limit_exceeded"):
3227
+ print_error(
3228
+ "Selection Too Large",
3229
+ str(preview.get("message") or "Too many memories."),
3230
+ )
3231
+ return
3232
+ prompt = (
3233
+ f"Move {count} {'memory' if count == 1 else 'memories'} "
3234
+ f"from {display_source_space} to {display_target_space}?"
3235
+ )
3236
+ if not Confirm.ask(prompt):
3237
+ console.print("[dim]Cancelled[/dim]")
3238
+ return
3239
+
3240
+ if not is_json_mode():
3241
+ with Progress(
3242
+ SpinnerColumn(),
3243
+ TextColumn("[cyan]Moving memories...[/cyan]"),
3244
+ console=console,
3245
+ transient=True,
3246
+ ) as p:
3247
+ p.add_task("", total=None)
3248
+ result = api_post("/memories/bulk/move", request)
3249
+ else:
3250
+ result = api_post("/memories/bulk/move", request)
3251
+
3252
+ if is_json_mode():
3253
+ output_json(result)
3254
+ return
3255
+
3256
+ moved = int(result.get("moved_count", 0) or 0)
3257
+ failed = int(result.get("failed_count", 0) or 0)
3258
+ if failed:
3259
+ print_error(
3260
+ "Move completed with failures",
3261
+ f"Moved {moved}, failed {failed}.",
3262
+ )
3263
+ else:
3264
+ print_success(
3265
+ "Moved memories",
3266
+ f"{moved} to {display_target_space}",
3267
+ )
3268
+
3269
+
3270
+ def cmd_memories_delete(
3271
+ memory_ids: list[str],
3272
+ force: bool = False,
3273
+ dry_run: bool = False,
3274
+ space_id: str | None = None,
3275
+ ) -> None:
3276
+ normalized_ids = _normalize_cli_ids(memory_ids)
3010
3277
 
3011
3278
  if not normalized_ids:
3012
3279
  if is_json_mode():
@@ -3953,6 +4220,183 @@ def cmd_threads_delete(
3953
4220
  console.print(f" [dim]{', '.join(details)}[/dim]")
3954
4221
 
3955
4222
 
4223
+ def _thread_move_selection_payload(
4224
+ thread_ids: list[str],
4225
+ *,
4226
+ source_space_id: str | None,
4227
+ all_in_space: str | None,
4228
+ exclude_thread_ids: list[str] | None,
4229
+ ) -> dict[str, Any] | None:
4230
+ normalized_ids = _normalize_cli_ids(thread_ids)
4231
+ source_space = _selection_source_space(source_space_id, all_in_space)
4232
+ if source_space == _PREVIEW_ERROR:
4233
+ return None
4234
+
4235
+ if all_in_space and normalized_ids:
4236
+ if is_json_mode():
4237
+ output_json(
4238
+ {
4239
+ "error": "conflicting_selection",
4240
+ "message": "Pass thread IDs or --all-in-space, not both.",
4241
+ }
4242
+ )
4243
+ else:
4244
+ print_error(
4245
+ "Conflicting Selection",
4246
+ "Pass thread IDs or --all-in-space, not both.",
4247
+ )
4248
+ return None
4249
+
4250
+ if all_in_space:
4251
+ return {
4252
+ "mode": "space",
4253
+ "source_space_id": source_space,
4254
+ "exclude_thread_ids": _normalize_cli_ids(exclude_thread_ids),
4255
+ }
4256
+
4257
+ if not normalized_ids:
4258
+ if is_json_mode():
4259
+ output_json(
4260
+ {
4261
+ "error": "invalid_input",
4262
+ "message": "Provide thread IDs or --all-in-space.",
4263
+ }
4264
+ )
4265
+ else:
4266
+ print_error(
4267
+ "Invalid Input",
4268
+ "Provide thread IDs or --all-in-space.",
4269
+ "Example: nmem t move thread-1 thread-2 "
4270
+ "--space Research --to-space Archive",
4271
+ )
4272
+ return None
4273
+
4274
+ payload: dict[str, Any] = {
4275
+ "mode": "ids",
4276
+ "thread_ids": normalized_ids,
4277
+ }
4278
+ if source_space:
4279
+ payload["source_space_id"] = source_space
4280
+ return payload
4281
+
4282
+
4283
+ def cmd_threads_move(
4284
+ thread_ids: list[str],
4285
+ *,
4286
+ target_space_id: str,
4287
+ source_space_id: str | None = None,
4288
+ all_in_space: str | None = None,
4289
+ exclude_thread_ids: list[str] | None = None,
4290
+ force: bool = False,
4291
+ dry_run: bool = False,
4292
+ ) -> None:
4293
+ """Move explicit threads, or every thread in one source space, to another."""
4294
+ target_space = str(target_space_id or "").strip()
4295
+ if not target_space:
4296
+ if is_json_mode():
4297
+ output_json(
4298
+ {"error": "missing_target_space", "message": "Pass --to-space."}
4299
+ )
4300
+ else:
4301
+ print_error("Missing Target Space", "Pass --to-space <space name>.")
4302
+ return
4303
+
4304
+ selection = _thread_move_selection_payload(
4305
+ thread_ids,
4306
+ source_space_id=source_space_id,
4307
+ all_in_space=all_in_space,
4308
+ exclude_thread_ids=exclude_thread_ids,
4309
+ )
4310
+ if selection is None:
4311
+ return
4312
+
4313
+ request = {"selection": selection, "target_space_id": target_space}
4314
+ preview = api_post("/threads/bulk/move/preview", request)
4315
+ display_source_space = _display_space_name(
4316
+ preview,
4317
+ "source_space_id",
4318
+ selection.get("source_space_id"),
4319
+ )
4320
+ display_target_space = _display_space_name(
4321
+ preview,
4322
+ "target_space_id",
4323
+ target_space,
4324
+ )
4325
+ if dry_run:
4326
+ if is_json_mode():
4327
+ output_json({"dry_run": True, **preview})
4328
+ else:
4329
+ console.print("[bold]Dry run[/bold] — no changes will be made.")
4330
+ console.print(
4331
+ f" [cyan]{preview.get('count', 0)}[/cyan] threads would move "
4332
+ f"from [cyan]{display_source_space}[/cyan] "
4333
+ f"to [cyan]{display_target_space}[/cyan]."
4334
+ )
4335
+ conflicts = preview.get("conflicts") or []
4336
+ if conflicts:
4337
+ console.print(
4338
+ f" [yellow]{len(conflicts)} ambiguous legacy threads "
4339
+ "need review.[/yellow]"
4340
+ )
4341
+ if preview.get("limit_exceeded"):
4342
+ console.print(f" [yellow]{preview.get('message')}[/yellow]")
4343
+ return
4344
+
4345
+ if not force and not is_json_mode():
4346
+ count = int(preview.get("count", 0) or 0)
4347
+ conflicts = preview.get("conflicts") or []
4348
+ if preview.get("limit_exceeded"):
4349
+ print_error(
4350
+ "Selection Too Large",
4351
+ str(preview.get("message") or "Too many threads."),
4352
+ )
4353
+ return
4354
+ if conflicts:
4355
+ print_error(
4356
+ "Review Required",
4357
+ f"{len(conflicts)} threads are ambiguous legacy records "
4358
+ "and cannot be moved safely.",
4359
+ )
4360
+ return
4361
+ prompt = (
4362
+ f"Move {count} {'thread' if count == 1 else 'threads'} "
4363
+ f"from {display_source_space} to {display_target_space}?"
4364
+ )
4365
+ if not Confirm.ask(prompt):
4366
+ console.print("[dim]Cancelled[/dim]")
4367
+ return
4368
+
4369
+ if not is_json_mode():
4370
+ with Progress(
4371
+ SpinnerColumn(),
4372
+ TextColumn("[cyan]Moving threads...[/cyan]"),
4373
+ console=console,
4374
+ transient=True,
4375
+ ) as p:
4376
+ p.add_task("", total=None)
4377
+ result = api_post("/threads/bulk/move", request)
4378
+ else:
4379
+ result = api_post("/threads/bulk/move", request)
4380
+
4381
+ if is_json_mode():
4382
+ output_json(result)
4383
+ return
4384
+
4385
+ moved = int(result.get("moved_count", 0) or 0)
4386
+ failed = int(result.get("failed_count", 0) or 0)
4387
+ conflicts = result.get("conflicts") or []
4388
+ if failed or conflicts:
4389
+ print_error(
4390
+ "Move completed with failures",
4391
+ f"Moved {moved}, failed {failed}.",
4392
+ )
4393
+ else:
4394
+ print_success(
4395
+ "Moved threads",
4396
+ f"{moved} to {display_target_space}",
4397
+ )
4398
+
4399
+
3956
4400
  def cmd_threads_append(
3957
4401
  thread_id: str,
3958
4402
  messages_json: str | None = None,
@@ -5901,6 +6345,9 @@ def cmd_service_logs(user: bool, follow: bool, lines: int) -> None:
5901
6345
 
5902
6346
  _UPDATE_CHECK_URL = "https://backbone-mem.nowledge.co/latest"
5903
6347
  _UPDATE_USER_AGENT = f"nmem-cli/{__version__}"
6348
+ _APT_TARGETED_UPGRADE_COMMAND = (
6349
+ "sudo apt-get update && sudo apt-get install --only-upgrade nowledge-mem"
6350
+ )
5904
6351
 
5905
6352
 
5906
6353
  def _detect_deployment_type() -> str:
@@ -5979,6 +6426,126 @@ def _check_for_update(platform_id: str) -> Optional[dict]:
5979
6426
  return None
5980
6427
 
5981
6428
 
6429
+ def _parse_apt_policy_version(output: str, field: str) -> Optional[str]:
6430
+ """Extract Installed/Candidate version from `apt-cache policy` output."""
6431
+ match = re.search(rf"^\s*{re.escape(field)}:\s*(.+)$", output, re.MULTILINE)
6432
+ if not match:
6433
+ return None
6434
+ value = match.group(1).strip()
6435
+ if value in {"", "(none)"}:
6436
+ return None
6437
+ return value
6438
+
6439
+
6440
+ def _is_deb_version_newer(current: str, candidate: str) -> bool:
6441
+ """Compare DEB versions using dpkg semantics when available."""
6442
+ try:
6443
+ result = subprocess.run(
6444
+ ["dpkg", "--compare-versions", candidate, "gt", current],
6445
+ capture_output=True,
6446
+ text=True,
6447
+ timeout=5,
6448
+ )
6449
+ except (FileNotFoundError, subprocess.TimeoutExpired):
6450
+ return _compare_versions(current, candidate)
6451
+
6452
+ if result.returncode == 0:
6453
+ return True
6454
+ if result.returncode == 1:
6455
+ return False
6456
+ return _compare_versions(current, candidate)
6457
+
6458
+
6459
+ def _get_apt_policy_versions() -> tuple[Optional[str], Optional[str]]:
6460
+ """Return (installed, candidate) versions known to local APT metadata."""
6461
+ try:
6462
+ result = subprocess.run(
6463
+ ["apt-cache", "policy", "nowledge-mem"],
6464
+ capture_output=True,
6465
+ text=True,
6466
+ timeout=10,
6467
+ )
6468
+ except (FileNotFoundError, subprocess.TimeoutExpired):
6469
+ return None, None
6470
+
6471
+ if result.returncode != 0:
6472
+ return None, None
6473
+
6474
+ return (
6475
+ _parse_apt_policy_version(result.stdout, "Installed"),
6476
+ _parse_apt_policy_version(result.stdout, "Candidate"),
6477
+ )
6478
+
6479
+
6480
+ def _get_dpkg_installed_version() -> Optional[str]:
6481
+ """Read installed DEB version from dpkg when available."""
6482
+ try:
6483
+ result = subprocess.run(
6484
+ ["dpkg-query", "-W", "-f", "${Version}", "nowledge-mem"],
6485
+ capture_output=True,
6486
+ text=True,
6487
+ timeout=5,
6488
+ )
6489
+ except (FileNotFoundError, subprocess.TimeoutExpired):
6490
+ return None
6491
+
6492
+ if result.returncode != 0:
6493
+ return None
6494
+
6495
+ version = result.stdout.strip()
6496
+ return version or None
6497
+
6498
+
6499
+ def _build_deb_update_result(current_version: str, update_info: Optional[dict]) -> dict:
6500
+ """Merge release-server and local APT state into one honest update result."""
6501
+ release_version = ""
6502
+ release_notes = ""
6503
+ pub_date = ""
6504
+ file_size = None
6505
+ if isinstance(update_info, dict):
6506
+ release_version = str(update_info.get("version", "") or "")
6507
+ release_notes = str(update_info.get("release_notes", "") or "")
6508
+ pub_date = str(update_info.get("pub_date", "") or "")
6509
+ file_size = update_info.get("file_size")
6510
+
6511
+ apt_installed, apt_candidate = _get_apt_policy_versions()
6512
+ installed_version = _get_dpkg_installed_version() or apt_installed or current_version
6513
+ apt_repo_configured = _is_apt_repo_configured()
6514
+ release_newer = bool(release_version and _is_deb_version_newer(installed_version, release_version))
6515
+ if not apt_repo_configured:
6516
+ return {
6517
+ "current_version": installed_version,
6518
+ "available_version": release_version,
6519
+ "update_available": release_newer,
6520
+ "deployment_type": "deb",
6521
+ "release_notes": release_notes,
6522
+ "pub_date": pub_date,
6523
+ "file_size": file_size,
6524
+ "release_version": release_version,
6525
+ "apt_candidate_version": apt_candidate,
6526
+ "apt_metadata_stale": False,
6527
+ "apt_repo_configured": False,
6528
+ }
6529
+
6530
+ installable_update = bool(apt_candidate and _is_deb_version_newer(installed_version, apt_candidate))
6531
+ apt_metadata_stale = release_newer and not installable_update
6532
+ available_version = release_version if apt_metadata_stale else (apt_candidate or release_version)
6533
+
6534
+ return {
6535
+ "current_version": installed_version,
6536
+ "available_version": available_version,
6537
+ "update_available": installable_update,
6538
+ "deployment_type": "deb",
6539
+ "release_notes": release_notes,
6540
+ "pub_date": pub_date,
6541
+ "file_size": file_size,
6542
+ "release_version": release_version,
6543
+ "apt_candidate_version": apt_candidate,
6544
+ "apt_metadata_stale": apt_metadata_stale,
6545
+ "apt_repo_configured": True,
6546
+ }
6547
+
6548
+
5982
6549
  def _is_apt_repo_configured() -> bool:
5983
6550
  """Check if the Nowledge Mem APT source and GPG key are in place."""
5984
6551
  return (
@@ -6037,50 +6604,85 @@ def cmd_update_check() -> None:
6037
6604
  p.add_task("", total=None)
6038
6605
  update_info = _check_for_update(platform_id)
6039
6606
 
6040
- if update_info is None:
6041
- if is_json_mode():
6042
- output_json({"error": "check_failed", "message": "Could not reach update server"})
6043
- else:
6044
- print_error("Check Failed", "Could not reach update server.",
6045
- "Check your internet connection and try again.")
6046
- return
6047
-
6048
- available = update_info.get("version", "")
6049
- is_newer = _compare_versions(__version__, available)
6050
-
6051
- result = {
6052
- "current_version": __version__,
6053
- "available_version": available,
6054
- "update_available": is_newer,
6055
- "deployment_type": deploy_type,
6056
- "release_notes": update_info.get("release_notes", ""),
6057
- "pub_date": update_info.get("pub_date", ""),
6058
- "file_size": update_info.get("file_size"),
6059
- }
6607
+ if deploy_type == "deb":
6608
+ result = _build_deb_update_result(__version__, update_info)
6609
+ else:
6610
+ if update_info is None:
6611
+ if is_json_mode():
6612
+ output_json({"error": "check_failed", "message": "Could not reach update server"})
6613
+ else:
6614
+ print_error("Check Failed", "Could not reach update server.",
6615
+ "Check your internet connection and try again.")
6616
+ return
6617
+ available = update_info.get("version", "")
6618
+ is_newer = _compare_versions(__version__, available)
6619
+ result = {
6620
+ "current_version": __version__,
6621
+ "available_version": available,
6622
+ "update_available": is_newer,
6623
+ "deployment_type": deploy_type,
6624
+ "release_notes": update_info.get("release_notes", ""),
6625
+ "pub_date": update_info.get("pub_date", ""),
6626
+ "file_size": update_info.get("file_size"),
6627
+ }
6060
6628
 
6061
6629
  if is_json_mode():
6062
6630
  output_json(result)
6063
6631
  return
6064
6632
 
6065
6633
  console.print()
6066
- if is_newer:
6634
+ if result["update_available"]:
6067
6635
  console.print(
6068
6636
  f"[bold green]Update available[/bold green] "
6069
- f"[dim]{__version__}[/dim] -> [bold cyan]{available}[/bold cyan]"
6637
+ f"[dim]{result['current_version']}[/dim] -> [bold cyan]{result['available_version']}[/bold cyan]"
6070
6638
  )
6071
- console.print(f" Deployment: [cyan]{deploy_type}[/cyan]")
6072
- notes = update_info.get("release_notes", "")
6639
+ console.print(f" Deployment: [cyan]{result['deployment_type']}[/cyan]")
6640
+ notes = result.get("release_notes", "")
6073
6641
  if notes:
6074
6642
  console.print(f" Notes: {notes[:200]}")
6075
6643
  console.print()
6076
6644
  if deploy_type == "deb":
6077
- console.print(" Run [bold]sudo nmem update apply[/bold] or:")
6078
- console.print(" [dim]sudo apt-get update && sudo apt-get upgrade nowledge-mem[/dim]")
6645
+ if result.get("apt_repo_configured"):
6646
+ console.print(" Run [bold]sudo nmem update apply[/bold] or:")
6647
+ console.print(f" [dim]{_APT_TARGETED_UPGRADE_COMMAND}[/dim]")
6648
+ else:
6649
+ console.print(" [yellow]APT repository is not configured on this machine.[/yellow]")
6650
+ console.print(
6651
+ " [dim]Reinstall the .deb package, or run the repository setup script first:[/dim]"
6652
+ )
6653
+ console.print(
6654
+ " [dim]curl -fsSL https://nowledge-co.github.io/community/apt/install.sh | sudo bash[/dim]"
6655
+ )
6079
6656
  else:
6080
6657
  console.print(" Run [bold]nmem update apply[/bold] to download and install.")
6658
+ elif deploy_type == "deb" and result.get("apt_metadata_stale"):
6659
+ release_version = result.get("release_version") or "a newer release"
6660
+ candidate_version = result.get("apt_candidate_version") or result["current_version"]
6661
+ console.print(
6662
+ "[yellow]Release published, but this machine's APT metadata is still stale[/yellow]"
6663
+ )
6664
+ console.print(
6665
+ f" Release: [bold cyan]{release_version}[/bold cyan] "
6666
+ f"APT candidate: [cyan]{candidate_version}[/cyan]"
6667
+ )
6668
+ console.print()
6669
+ console.print(f" Refresh APT first: [bold]{_APT_TARGETED_UPGRADE_COMMAND}[/bold]")
6670
+ console.print(
6671
+ " [dim]If APT still offers the old version after refresh, the repo has not published"
6672
+ " this release yet.[/dim]"
6673
+ )
6674
+ elif deploy_type == "deb" and update_info is None:
6675
+ console.print(
6676
+ "[yellow]Release server unavailable.[/yellow] Showing the installable version known to local APT."
6677
+ )
6678
+ console.print()
6679
+ console.print(
6680
+ f"[green]ok[/green] You are on the latest version APT currently offers "
6681
+ f"([cyan]{result['current_version']}[/cyan])"
6682
+ )
6081
6683
  else:
6082
6684
  console.print(
6083
- f"[green]ok[/green] You are on the latest version ([cyan]{__version__}[/cyan])"
6685
+ f"[green]ok[/green] You are on the latest version ([cyan]{result['current_version']}[/cyan])"
6084
6686
  )
6085
6687
  console.print()
6086
6688
 
@@ -6100,7 +6702,7 @@ def cmd_update_apply(yes: bool = False) -> None:
6100
6702
  print_error(
6101
6703
  "Unknown Deployment",
6102
6704
  "Cannot determine if installed via DEB or AppImage.",
6103
- "For DEB: sudo apt-get update && sudo apt-get upgrade nowledge-mem\n"
6705
+ f"For DEB: {_APT_TARGETED_UPGRADE_COMMAND}\n"
6104
6706
  "For AppImage: set the APPIMAGE env var and re-run.",
6105
6707
  )
6106
6708
  return
@@ -6192,6 +6794,39 @@ def _update_deb(yes: bool) -> None:
6192
6794
  print_error("APT Update Failed", result.stderr.strip())
6193
6795
  return
6194
6796
 
6797
+ installed_version_before = _get_dpkg_installed_version() or __version__
6798
+ _, apt_candidate_version = _get_apt_policy_versions()
6799
+ if not (apt_candidate_version and _is_deb_version_newer(installed_version_before, apt_candidate_version)):
6800
+ latest_release = ""
6801
+ update_info = _check_for_update("linux-deb")
6802
+ if isinstance(update_info, dict):
6803
+ latest_release = str(update_info.get("version", "") or "")
6804
+ if is_json_mode():
6805
+ output_json(
6806
+ {
6807
+ "status": "apt_not_ready",
6808
+ "installed_version": installed_version_before,
6809
+ "apt_candidate_version": apt_candidate_version or installed_version_before,
6810
+ "release_version": latest_release or None,
6811
+ }
6812
+ )
6813
+ else:
6814
+ if latest_release and _is_deb_version_newer(installed_version_before, latest_release):
6815
+ print_error(
6816
+ "APT Repository Not Ready",
6817
+ (
6818
+ f"Release {latest_release} is published, but APT still offers "
6819
+ f"{apt_candidate_version or installed_version_before}."
6820
+ ),
6821
+ "Wait a moment and run the command again. If it still stays on the old "
6822
+ "version, the APT repo has not published this release yet.",
6823
+ )
6824
+ else:
6825
+ console.print(
6826
+ f"[green]ok[/green] Already on the latest version ([cyan]{installed_version_before}[/cyan])"
6827
+ )
6828
+ return
6829
+
6195
6830
  # 6. Install upgrade
6196
6831
  try:
6197
6832
  with Progress(
@@ -6484,8 +7119,11 @@ def cmd_models_status(verify: bool = False, force_refresh: bool = False) -> None
6484
7119
  params["force_refresh"] = True
6485
7120
 
6486
7121
  data = api_get("/models/status", params=params or None)
7122
+ search_index_status = api_get("/models/bge-m3/status")
6487
7123
  if is_json_mode():
6488
- output_json(data)
7124
+ payload = dict(data)
7125
+ payload["search_index"] = _summarize_search_index_status(search_index_status)
7126
+ output_json(payload)
6489
7127
  return
6490
7128
 
6491
7129
  console.print()
@@ -6554,6 +7192,22 @@ def cmd_models_status(verify: bool = False, force_refresh: bool = False) -> None
6554
7192
  console.print(" [yellow]Some models are installed but not runtime-verified.[/yellow]")
6555
7193
  console.print(" Run [bold]nmem models status --verify[/bold] for full validation.")
6556
7194
 
7195
+ search_index = _summarize_search_index_status(search_index_status)
7196
+ console.print()
7197
+ console.print("[bold]Search Index[/bold]")
7198
+ if search_index["status"] == "rebuild_needed":
7199
+ console.print(" [yellow]Action needed:[/yellow] rebuild search index")
7200
+ console.print(f" {search_index['message']}")
7201
+ console.print(" Run [bold]nmem models reindex[/bold] when convenient.")
7202
+ elif search_index["status"] == "updating_metadata":
7203
+ console.print(" [cyan]Updating metadata[/cyan]")
7204
+ console.print(f" {search_index['message']}")
7205
+ console.print(" No rebuild or embedding call is needed.")
7206
+ elif search_index["status"] == "ready":
7207
+ console.print(" [green]Ready[/green]")
7208
+ else:
7209
+ console.print(" [dim]Search model not available yet.[/dim]")
7210
+
6557
7211
  console.print()
6558
7212
 
6559
7213
 
@@ -7811,8 +8465,12 @@ EXAMPLES
7811
8465
  nmem m List memories (alias)
7812
8466
  nmem m search "query" Search memories
7813
8467
  nmem m add "content" Add memory
8468
+ nmem m move mem-1 --space Research --to-space Archive
8469
+ Move memories between spaces
7814
8470
  nmem t List threads (alias)
7815
8471
  nmem t create -t "Title" -f x.md Create from file
8472
+ nmem t move thread-1 --space Research --to-space Archive
8473
+ Move threads between spaces
7816
8474
  nmem t append <id> -m '[{{"role":"user","content":"..."}}]' Append messages
7817
8475
  nmem wm Read today's Working Memory
7818
8476
  nmem wm --date 2026-02-12 Read archived WM
@@ -8217,6 +8875,44 @@ PRIORITY
8217
8875
  u.add_argument("-c", "--content")
8218
8876
  u.add_argument("-i", "--importance", type=float, help="New importance 0.0-1.0")
8219
8877
 
8878
+ mv = mem_subs.add_parser(
8879
+ "move",
8880
+ help="Move memories to another space",
8881
+ parents=[_space_parent],
8882
+ epilog="""examples:
8883
+ nmem m move mem-abc123 --space Research --to-space Archive
8884
+ nmem m move mem-1 mem-2 --space "Agent A" --to-space "Agent B" --force
8885
+ nmem m move --all-in-space Default --to-space Archive --force
8886
+ nmem m move --all-in-space Research --exclude mem-keep --to-space Archive --dry-run""",
8887
+ formatter_class=argparse.RawDescriptionHelpFormatter,
8888
+ )
8889
+ mv.add_argument("id", nargs="*", help="Memory ID(s) to move")
8890
+ mv.add_argument(
8891
+ "--to-space",
8892
+ required=True,
8893
+ dest="target_space_id",
8894
+ help="Target space name",
8895
+ )
8896
+ mv.add_argument(
8897
+ "--all-in-space",
8898
+ dest="all_in_space",
8899
+ help=(
8900
+ "Move every memory in this source space. Requires confirmation "
8901
+ "unless --force is passed."
8902
+ ),
8903
+ )
8904
+ mv.add_argument(
8905
+ "--exclude",
8906
+ dest="exclude_memory_ids",
8907
+ action="append",
8908
+ help="Memory ID to keep when using --all-in-space (repeatable)",
8909
+ )
8910
+ mv.add_argument("-f", "--force", action="store_true")
8911
+ mv.add_argument(
8912
+ "--dry-run", action="store_true",
8913
+ help="Preview the move without making changes",
8914
+ )
8915
+
8220
8916
  d = mem_subs.add_parser(
8221
8917
  "delete",
8222
8918
  help="Delete",
@@ -8418,6 +9114,44 @@ PRIORITY
8418
9114
  help="Preview what would be deleted without making changes",
8419
9115
  )
8420
9116
 
9117
+ mv = thr_subs.add_parser(
9118
+ "move",
9119
+ help="Move threads to another space",
9120
+ parents=[_space_parent],
9121
+ epilog="""examples:
9122
+ nmem t move thread-abc123 --space Research --to-space Archive
9123
+ nmem t move thread-1 thread-2 --space "Agent A" --to-space "Agent B" --force
9124
+ nmem t move --all-in-space Default --to-space Archive --force
9125
+ nmem t move --all-in-space Research --exclude thread-keep --to-space Archive --dry-run""",
9126
+ formatter_class=argparse.RawDescriptionHelpFormatter,
9127
+ )
9128
+ mv.add_argument("id", nargs="*", help="Thread ID(s) to move")
9129
+ mv.add_argument(
9130
+ "--to-space",
9131
+ required=True,
9132
+ dest="target_space_id",
9133
+ help="Target space name",
9134
+ )
9135
+ mv.add_argument(
9136
+ "--all-in-space",
9137
+ dest="all_in_space",
9138
+ help=(
9139
+ "Move every thread in this source space. Requires confirmation "
9140
+ "unless --force is passed."
9141
+ ),
9142
+ )
9143
+ mv.add_argument(
9144
+ "--exclude",
9145
+ dest="exclude_thread_ids",
9146
+ action="append",
9147
+ help="Thread ID to keep when using --all-in-space (repeatable)",
9148
+ )
9149
+ mv.add_argument("-f", "--force", action="store_true")
9150
+ mv.add_argument(
9151
+ "--dry-run", action="store_true",
9152
+ help="Preview the move without making changes",
9153
+ )
9154
+
8421
9155
  # save - Save coding session as thread
8422
9156
  sv = thr_subs.add_parser(
8423
9157
  "save",
@@ -9237,7 +9971,23 @@ def main() -> int:
9237
9971
  getattr(args, "space", None),
9238
9972
  )
9239
9973
  elif action == "update":
9240
- cmd_memories_update(args.id, args.title, args.content, args.importance, getattr(args, "space", None))
9974
+ cmd_memories_update(
9975
+ args.id,
9976
+ args.title,
9977
+ args.content,
9978
+ args.importance,
9979
+ getattr(args, "space", None),
9980
+ )
9981
+ elif action == "move":
9982
+ cmd_memories_move(
9983
+ getattr(args, "id", []),
9984
+ target_space_id=args.target_space_id,
9985
+ source_space_id=getattr(args, "space", None),
9986
+ all_in_space=getattr(args, "all_in_space", None),
9987
+ exclude_memory_ids=getattr(args, "exclude_memory_ids", None),
9988
+ force=getattr(args, "force", False),
9989
+ dry_run=getattr(args, "dry_run", False),
9990
+ )
9241
9991
  elif action == "delete":
9242
9992
  cmd_memories_delete(
9243
9993
  args.id,
@@ -9246,7 +9996,11 @@ def main() -> int:
9246
9996
  getattr(args, "space", None),
9247
9997
  )
9248
9998
  else:
9249
- cmd_memories_list(args.limit, getattr(args, "importance", None), getattr(args, "space", None))
9999
+ cmd_memories_list(
10000
+ args.limit,
10001
+ getattr(args, "importance", None),
10002
+ getattr(args, "space", None),
10003
+ )
9250
10004
  elif cmd in ("threads", "t"):
9251
10005
  action = args.action
9252
10006
  if action == "list":
@@ -9303,7 +10057,23 @@ def main() -> int:
9303
10057
  idempotency_key=getattr(args, "idempotency_key", None),
9304
10058
  )
9305
10059
  elif action == "delete":
9306
- cmd_threads_delete(args.id, args.force, getattr(args, "cascade", False), getattr(args, "dry_run", False), getattr(args, "space", None))
10060
+ cmd_threads_delete(
10061
+ args.id,
10062
+ args.force,
10063
+ getattr(args, "cascade", False),
10064
+ getattr(args, "dry_run", False),
10065
+ getattr(args, "space", None),
10066
+ )
10067
+ elif action == "move":
10068
+ cmd_threads_move(
10069
+ getattr(args, "id", []),
10070
+ target_space_id=args.target_space_id,
10071
+ source_space_id=getattr(args, "space", None),
10072
+ all_in_space=getattr(args, "all_in_space", None),
10073
+ exclude_thread_ids=getattr(args, "exclude_thread_ids", None),
10074
+ force=getattr(args, "force", False),
10075
+ dry_run=getattr(args, "dry_run", False),
10076
+ )
9307
10077
  elif action == "save":
9308
10078
  cmd_threads_save(
9309
10079
  client=args.source_app,
@@ -37,6 +37,12 @@ class ApiClient:
37
37
  or os.environ.get("NMEM_API_URL")
38
38
  or str(config.get("apiUrl") or config.get("api_url") or DEFAULT_API_URL)
39
39
  ).rstrip("/")
40
+ self.space_id = (
41
+ os.environ.get("NMEM_SPACE")
42
+ or os.environ.get("NMEM_SPACE_ID")
43
+ or str(config.get("space") or config.get("space_id") or "").strip()
44
+ or None
45
+ )
40
46
  env_api_key = os.environ.get("NMEM_API_KEY")
41
47
  if env_api_key is not None:
42
48
  api_key = env_api_key.strip()
@@ -76,6 +82,21 @@ class ApiClient:
76
82
  )
77
83
  return self._client
78
84
 
85
+ def _with_space_params(
86
+ self,
87
+ params: dict[str, Any] | None = None,
88
+ ) -> dict[str, Any]:
89
+ scoped = dict(params or {})
90
+ if self.space_id:
91
+ scoped["space_id"] = self.space_id
92
+ return scoped
93
+
94
+ def _with_space_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
95
+ scoped = dict(payload)
96
+ if self.space_id and "space_id" not in scoped:
97
+ scoped["space_id"] = self.space_id
98
+ return scoped
99
+
79
100
  async def close(self) -> None:
80
101
  """Close the HTTP client."""
81
102
  if self._client and not self._client.is_closed:
@@ -142,7 +163,7 @@ class ApiClient:
142
163
  params: dict[str, Any] = {"limit": limit, "offset": offset}
143
164
  if importance_min > 0:
144
165
  params["importance_min"] = importance_min
145
- response = await client.get("/memories", params=params)
166
+ response = await client.get("/memories", params=self._with_space_params(params))
146
167
  response.raise_for_status()
147
168
  return response.json()
148
169
 
@@ -161,14 +182,20 @@ class ApiClient:
161
182
  params["labels"] = labels
162
183
  if importance_min > 0:
163
184
  params["importance_min"] = importance_min
164
- response = await client.get("/memories/search", params=params)
185
+ response = await client.get(
186
+ "/memories/search",
187
+ params=self._with_space_params(params),
188
+ )
165
189
  response.raise_for_status()
166
190
  return response.json()
167
191
 
168
192
  async def get_memory(self, memory_id: str) -> dict[str, Any]:
169
193
  """Get a single memory by ID."""
170
194
  client = await self._get_client()
171
- response = await client.get(f"/memories/{memory_id}")
195
+ response = await client.get(
196
+ f"/memories/{memory_id}",
197
+ params=self._with_space_params(),
198
+ )
172
199
  response.raise_for_status()
173
200
  return response.json()
174
201
 
@@ -200,14 +227,20 @@ class ApiClient:
200
227
  payload["event_end"] = event_end
201
228
  if temporal_context:
202
229
  payload["temporal_context"] = temporal_context
203
- response = await client.post("/memories", json=payload)
230
+ response = await client.post(
231
+ "/memories",
232
+ json=self._with_space_payload(payload),
233
+ )
204
234
  response.raise_for_status()
205
235
  return response.json()
206
236
 
207
237
  async def delete_memory(self, memory_id: str) -> dict[str, Any]:
208
238
  """Delete a memory."""
209
239
  client = await self._get_client()
210
- response = await client.delete(f"/memories/{memory_id}")
240
+ response = await client.delete(
241
+ f"/memories/{memory_id}",
242
+ params=self._with_space_params(),
243
+ )
211
244
  response.raise_for_status()
212
245
  return response.json()
213
246
 
@@ -227,7 +260,10 @@ class ApiClient:
227
260
  payload["content"] = content
228
261
  if importance is not None:
229
262
  payload["importance"] = importance
230
- response = await client.patch(f"/memories/{memory_id}", json=payload)
263
+ response = await client.patch(
264
+ f"/memories/{memory_id}",
265
+ json=self._with_space_payload(payload),
266
+ )
231
267
  response.raise_for_status()
232
268
  return response.json()
233
269
 
@@ -239,7 +275,7 @@ class ApiClient:
239
275
  """List threads with pagination."""
240
276
  client = await self._get_client()
241
277
  params = {"limit": limit, "offset": offset}
242
- response = await client.get("/threads", params=params)
278
+ response = await client.get("/threads", params=self._with_space_params(params))
243
279
  response.raise_for_status()
244
280
  return response.json()
245
281
 
@@ -247,14 +283,20 @@ class ApiClient:
247
283
  """Search threads."""
248
284
  client = await self._get_client()
249
285
  params = {"query": query, "limit": limit}
250
- response = await client.get("/threads/search", params=params)
286
+ response = await client.get(
287
+ "/threads/search",
288
+ params=self._with_space_params(params),
289
+ )
251
290
  response.raise_for_status()
252
291
  return response.json()
253
292
 
254
293
  async def get_thread(self, thread_id: str) -> dict[str, Any]:
255
294
  """Get a thread with messages."""
256
295
  client = await self._get_client()
257
- response = await client.get(f"/threads/{quote(thread_id, safe='')}")
296
+ response = await client.get(
297
+ f"/threads/{quote(thread_id, safe='')}",
298
+ params=self._with_space_params(),
299
+ )
258
300
  response.raise_for_status()
259
301
  return response.json()
260
302
 
@@ -264,7 +306,10 @@ class ApiClient:
264
306
  """Delete a thread."""
265
307
  client = await self._get_client()
266
308
  params = {"cascade_delete_memories": cascade}
267
- response = await client.delete(f"/threads/{quote(thread_id, safe='')}", params=params)
309
+ response = await client.delete(
310
+ f"/threads/{quote(thread_id, safe='')}",
311
+ params=self._with_space_params(params),
312
+ )
268
313
  response.raise_for_status()
269
314
  return response.json()
270
315
 
@@ -173,6 +173,8 @@ class NowledgeMemApp(App):
173
173
  def __init__(self) -> None:
174
174
  super().__init__()
175
175
  self.api_client = ApiClient()
176
+ if self.api_client.space_id:
177
+ self.SUB_TITLE = f"Space: {self.api_client.space_id}"
176
178
  self.register_theme(NOWLEDGE_THEME)
177
179
  self.theme = "nowledge"
178
180
 
File without changes
File without changes
File without changes