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.
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/PKG-INFO +1 -1
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/pyproject.toml +1 -1
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/__init__.py +1 -1
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/cli.py +810 -40
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/api_client.py +55 -10
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/app.py +2 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/.gitignore +0 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/README.md +0 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/license_payload.py +0 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/py.typed +0 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/session_import.py +0 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/__init__.py +0 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/__main__.py +0 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/__init__.py +0 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/dashboard.py +0 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/graph.py +0 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/help.py +0 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/memories.py +0 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/memory_detail.py +0 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/settings.py +0 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/thread_detail.py +0 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/screens/threads.py +0 -0
- {nmem_cli-0.7.0 → nmem_cli-0.7.6}/src/nmem_cli/tui/widgets/__init__.py +0 -0
|
@@ -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
|
|
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
|
|
3005
|
-
clean_id =
|
|
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
|
|
6041
|
-
|
|
6042
|
-
|
|
6043
|
-
|
|
6044
|
-
|
|
6045
|
-
|
|
6046
|
-
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
|
|
6057
|
-
|
|
6058
|
-
|
|
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
|
|
6634
|
+
if result["update_available"]:
|
|
6067
6635
|
console.print(
|
|
6068
6636
|
f"[bold green]Update available[/bold green] "
|
|
6069
|
-
f"[dim]{
|
|
6637
|
+
f"[dim]{result['current_version']}[/dim] -> [bold cyan]{result['available_version']}[/bold cyan]"
|
|
6070
6638
|
)
|
|
6071
|
-
console.print(f" Deployment: [cyan]{
|
|
6072
|
-
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
|
-
|
|
6078
|
-
|
|
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]{
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|