nmem-cli 0.9.0__tar.gz → 0.9.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/PKG-INFO +27 -6
  2. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/README.md +26 -5
  3. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/pyproject.toml +1 -1
  4. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/__init__.py +1 -1
  5. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/cli.py +412 -8
  6. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/session_import.py +516 -131
  7. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/.gitignore +0 -0
  8. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/agent_profiles.py +0 -0
  9. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/claude_paths.py +0 -0
  10. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/data_transfer_paths.py +0 -0
  11. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/guidance_rules.py +0 -0
  12. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/kfs_cli.py +0 -0
  13. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/license_payload.py +0 -0
  14. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/memories_reclassify.py +0 -0
  15. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/memory_relations_cli.py +0 -0
  16. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/py.typed +0 -0
  17. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/__init__.py +0 -0
  18. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/__main__.py +0 -0
  19. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/api_client.py +0 -0
  20. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/app.py +0 -0
  21. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/__init__.py +0 -0
  22. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/dashboard.py +0 -0
  23. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/graph.py +0 -0
  24. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/help.py +0 -0
  25. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/memories.py +0 -0
  26. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/memory_detail.py +0 -0
  27. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/settings.py +0 -0
  28. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/thread_detail.py +0 -0
  29. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/threads.py +0 -0
  30. {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/widgets/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nmem-cli
3
- Version: 0.9.0
3
+ Version: 0.9.3
4
4
  Summary: CLI and TUI for Nowledge Mem - AI memory management
5
5
  Project-URL: Homepage, https://mem.nowledge.co/
6
6
  Project-URL: Repository, https://github.com/nowledge-co/
@@ -119,6 +119,9 @@ nmem t
119
119
  | `nmem t save --from claude-code` | Save Claude Code session as thread |
120
120
  | `nmem t save --from codex` | Save Codex session as thread |
121
121
  | `nmem t save --from gemini-cli` | Save Gemini CLI session as thread |
122
+ | `nmem t sync --from codex --all-projects` | Preview historical Codex sessions across projects |
123
+ | `nmem t sync --from pi` | Preview historical Pi sessions |
124
+ | `nmem t sync --from pi --apply` | Import historical Pi sessions |
122
125
  | `nmem t delete <id>` | Delete a thread |
123
126
  | `nmem t delete <id> --dry-run` | Preview what would be deleted |
124
127
 
@@ -347,7 +350,7 @@ nmem t append openclaw-session-abc123 \
347
350
 
348
351
  ### Saving AI Coding Sessions
349
352
 
350
- Import conversations from Claude Code, Codex, or Gemini CLI as threads:
353
+ Import conversations from Claude Code, Codex, Gemini CLI, OpenCode, or Pi as threads:
351
354
 
352
355
  ```bash
353
356
  # Save current Claude Code session (uses current directory)
@@ -364,6 +367,16 @@ nmem t save --from codex -s "Implemented auth feature"
364
367
 
365
368
  # Save Gemini CLI session from the current project
366
369
  nmem t save --from gemini-cli
370
+
371
+ # Preview older Pi sessions, then import them
372
+ nmem t sync --from pi
373
+ nmem t sync --from pi --apply
374
+
375
+ # Preview older sessions across all projects for hosts with project-scoped storage
376
+ nmem t sync --from codex --all-projects --limit 20
377
+ nmem t sync --from claude-code --all-projects --limit 20
378
+ nmem t sync --from gemini-cli --all-projects --limit 20
379
+ nmem t sync --from opencode --all-projects --limit 20
367
380
  ```
368
381
 
369
382
  How it works:
@@ -372,21 +385,29 @@ How it works:
372
385
  - it parses those transcripts into normalized thread messages
373
386
  - it then uploads the resulting thread data to your configured Mem server
374
387
 
375
- By default, Claude Code and Codex are discovered from `~/.claude` and `~/.codex`. If you keep them somewhere else, `nmem` also respects `CLAUDE_CONFIG_DIR` and `CODEX_HOME` automatically.
388
+ By default, Claude Code, Codex, and Pi are discovered from `~/.claude`, `~/.codex`, and `~/.pi/agent`. If you keep them somewhere else, `nmem` also respects `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `PI_CODING_AGENT_DIR`, and `PI_CODING_AGENT_SESSION_DIR` automatically.
376
389
 
377
390
  That means `nmem t save --from ...` works correctly with remote Mem too: the server does not need direct access to those local agent directories on your laptop.
378
391
 
379
392
  Options:
380
- - `--from`: Source app (`claude-code`, `codex`, or `gemini-cli`) - required
393
+ - `--from`: Source app (`claude-code`, `codex`, `gemini-cli`, `opencode`, or `pi`) - required
381
394
  - `-p, --project`: Project directory (default: current dir)
382
395
  - `-m, --mode`: `current` (latest session) or `all` (all sessions)
383
396
  - `-s, --summary`: Brief session summary
384
397
  - `--session-id`: Specific session ID
385
398
  - `--truncate`: Truncate large tool results (>10KB)
386
399
 
387
- Re-running the command appends new messages with deduplication.
400
+ Use `nmem t sync --from ...` for deliberate historical backfills. It previews by default and requires `--apply` before writing. For Pi, `sync` scans all Pi sessions by default; pass `--session-dir` or `--project` to narrow it. For Claude Code, Codex, Gemini CLI, and OpenCode, the default stays scoped to the current project; add `--all-projects` when you intentionally want a broader import.
401
+
402
+ Re-running the command is safe. Each discovered session is processed independently. If a previous run stopped halfway through a batch, or created a thread but stopped before the batch finished, the next run detects the existing thread and appends with deduplication. If another process creates the thread between the existence check and the create request, the command falls through to the same deduplicated append path. Stable thread IDs, stable per-message `external_id`s, and an idempotency key keep repeated imports from duplicating messages.
403
+
404
+ This import path is distinct from desktop auto-sync and watcher-based ingestion:
405
+
406
+ - desktop auto-sync watches transcript files on the same machine as the Mem server
407
+ - plugin hooks and `nmem t save` / `nmem t sync` run on the machine where the agent is running, then upload to local or remote Mem through the same client config
408
+ - all paths use the same canonical source and thread IDs, so rerunning a hook, desktop watcher, or historical sync appends to the same thread instead of creating duplicates
388
409
 
389
- This import path is distinct from desktop auto-sync and watcher-based ingestion. File watching remains a local server-side capability; explicit CLI save is a client-side capture path.
410
+ Use the client-side path for remote Mem. A remote Mem server cannot scan your laptop's `~/.codex`, `~/.claude`, `~/.gemini`, `~/.pi`, or OpenCode session database by itself.
390
411
 
391
412
  ## Related
392
413
 
@@ -86,6 +86,9 @@ nmem t
86
86
  | `nmem t save --from claude-code` | Save Claude Code session as thread |
87
87
  | `nmem t save --from codex` | Save Codex session as thread |
88
88
  | `nmem t save --from gemini-cli` | Save Gemini CLI session as thread |
89
+ | `nmem t sync --from codex --all-projects` | Preview historical Codex sessions across projects |
90
+ | `nmem t sync --from pi` | Preview historical Pi sessions |
91
+ | `nmem t sync --from pi --apply` | Import historical Pi sessions |
89
92
  | `nmem t delete <id>` | Delete a thread |
90
93
  | `nmem t delete <id> --dry-run` | Preview what would be deleted |
91
94
 
@@ -314,7 +317,7 @@ nmem t append openclaw-session-abc123 \
314
317
 
315
318
  ### Saving AI Coding Sessions
316
319
 
317
- Import conversations from Claude Code, Codex, or Gemini CLI as threads:
320
+ Import conversations from Claude Code, Codex, Gemini CLI, OpenCode, or Pi as threads:
318
321
 
319
322
  ```bash
320
323
  # Save current Claude Code session (uses current directory)
@@ -331,6 +334,16 @@ nmem t save --from codex -s "Implemented auth feature"
331
334
 
332
335
  # Save Gemini CLI session from the current project
333
336
  nmem t save --from gemini-cli
337
+
338
+ # Preview older Pi sessions, then import them
339
+ nmem t sync --from pi
340
+ nmem t sync --from pi --apply
341
+
342
+ # Preview older sessions across all projects for hosts with project-scoped storage
343
+ nmem t sync --from codex --all-projects --limit 20
344
+ nmem t sync --from claude-code --all-projects --limit 20
345
+ nmem t sync --from gemini-cli --all-projects --limit 20
346
+ nmem t sync --from opencode --all-projects --limit 20
334
347
  ```
335
348
 
336
349
  How it works:
@@ -339,21 +352,29 @@ How it works:
339
352
  - it parses those transcripts into normalized thread messages
340
353
  - it then uploads the resulting thread data to your configured Mem server
341
354
 
342
- By default, Claude Code and Codex are discovered from `~/.claude` and `~/.codex`. If you keep them somewhere else, `nmem` also respects `CLAUDE_CONFIG_DIR` and `CODEX_HOME` automatically.
355
+ By default, Claude Code, Codex, and Pi are discovered from `~/.claude`, `~/.codex`, and `~/.pi/agent`. If you keep them somewhere else, `nmem` also respects `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `PI_CODING_AGENT_DIR`, and `PI_CODING_AGENT_SESSION_DIR` automatically.
343
356
 
344
357
  That means `nmem t save --from ...` works correctly with remote Mem too: the server does not need direct access to those local agent directories on your laptop.
345
358
 
346
359
  Options:
347
- - `--from`: Source app (`claude-code`, `codex`, or `gemini-cli`) - required
360
+ - `--from`: Source app (`claude-code`, `codex`, `gemini-cli`, `opencode`, or `pi`) - required
348
361
  - `-p, --project`: Project directory (default: current dir)
349
362
  - `-m, --mode`: `current` (latest session) or `all` (all sessions)
350
363
  - `-s, --summary`: Brief session summary
351
364
  - `--session-id`: Specific session ID
352
365
  - `--truncate`: Truncate large tool results (>10KB)
353
366
 
354
- Re-running the command appends new messages with deduplication.
367
+ Use `nmem t sync --from ...` for deliberate historical backfills. It previews by default and requires `--apply` before writing. For Pi, `sync` scans all Pi sessions by default; pass `--session-dir` or `--project` to narrow it. For Claude Code, Codex, Gemini CLI, and OpenCode, the default stays scoped to the current project; add `--all-projects` when you intentionally want a broader import.
368
+
369
+ Re-running the command is safe. Each discovered session is processed independently. If a previous run stopped halfway through a batch, or created a thread but stopped before the batch finished, the next run detects the existing thread and appends with deduplication. If another process creates the thread between the existence check and the create request, the command falls through to the same deduplicated append path. Stable thread IDs, stable per-message `external_id`s, and an idempotency key keep repeated imports from duplicating messages.
370
+
371
+ This import path is distinct from desktop auto-sync and watcher-based ingestion:
372
+
373
+ - desktop auto-sync watches transcript files on the same machine as the Mem server
374
+ - plugin hooks and `nmem t save` / `nmem t sync` run on the machine where the agent is running, then upload to local or remote Mem through the same client config
375
+ - all paths use the same canonical source and thread IDs, so rerunning a hook, desktop watcher, or historical sync appends to the same thread instead of creating duplicates
355
376
 
356
- This import path is distinct from desktop auto-sync and watcher-based ingestion. File watching remains a local server-side capability; explicit CLI save is a client-side capture path.
377
+ Use the client-side path for remote Mem. A remote Mem server cannot scan your laptop's `~/.codex`, `~/.claude`, `~/.gemini`, `~/.pi`, or OpenCode session database by itself.
357
378
 
358
379
  ## Related
359
380
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nmem-cli"
3
- version = "0.9.0"
3
+ version = "0.9.3"
4
4
  description = "CLI and TUI for Nowledge Mem - AI memory management"
5
5
  authors = [
6
6
  {name = "Nowledge Labs"}
@@ -20,7 +20,7 @@ Environment (overrides config file):
20
20
  NMEM_API_KEY Optional API key (Bearer auth)
21
21
  """
22
22
 
23
- __version__ = "0.9.0"
23
+ __version__ = "0.9.3"
24
24
  __author__ = "Nowledge Labs"
25
25
 
26
26
  from .cli import main
@@ -52,7 +52,13 @@ from .memory_relations_cli import (
52
52
  handle_memory_relation_command,
53
53
  register_memory_relation_parser,
54
54
  )
55
- from .session_import import SessionImportError, discover_sessions, parse_session
55
+ from .session_import import (
56
+ SUPPORTED_SESSION_CLIENTS,
57
+ SUPPORTED_SESSION_CLIENTS_TEXT,
58
+ SessionImportError,
59
+ discover_sessions,
60
+ parse_session,
61
+ )
56
62
 
57
63
  try:
58
64
  from nowledge_graph_server.utils.app_paths import resolve_config_file_path
@@ -5000,23 +5006,23 @@ def cmd_threads_save(
5000
5006
  ) -> None:
5001
5007
  """Save coding session(s) as conversation thread(s).
5002
5008
 
5003
- Supports Claude Code, Codex, Gemini CLI, and OpenCode. Auto-detects
5009
+ Supports Claude Code, Codex, Gemini CLI, OpenCode, and Pi. Auto-detects
5004
5010
  sessions from project path.
5005
5011
  """
5006
5012
  # Validate client
5007
- if client not in ["claude-code", "codex", "gemini-cli", "opencode"]:
5013
+ if client not in SUPPORTED_SESSION_CLIENTS:
5008
5014
  if is_json_mode():
5009
5015
  output_json(
5010
5016
  {
5011
5017
  "error": "invalid_client",
5012
- "message": "Must be claude-code, codex, gemini-cli, or opencode",
5018
+ "message": f"Must be one of: {SUPPORTED_SESSION_CLIENTS_TEXT}",
5013
5019
  }
5014
5020
  )
5015
5021
  else:
5016
5022
  print_error(
5017
5023
  "Invalid Client",
5018
5024
  f"'{client}' is not supported",
5019
- "Use 'claude-code', 'codex', 'gemini-cli', or 'opencode'",
5025
+ f"Use one of: {SUPPORTED_SESSION_CLIENTS_TEXT}",
5020
5026
  )
5021
5027
  sys.exit(1)
5022
5028
 
@@ -5171,6 +5177,339 @@ def cmd_threads_save(
5171
5177
  )
5172
5178
 
5173
5179
 
5180
+ def _write_thread_from_session_data(
5181
+ *,
5182
+ thread_data: dict[str, Any],
5183
+ session_id: str,
5184
+ session_file: Path,
5185
+ source_app: str,
5186
+ space_id: str | None,
5187
+ metadata_updates: dict[str, Any] | None = None,
5188
+ idempotency_prefix: str | None = None,
5189
+ ) -> dict[str, Any]:
5190
+ external_id_keys = (
5191
+ "external_id",
5192
+ "source_message_id",
5193
+ "message_id",
5194
+ "openclaw_entry_id",
5195
+ )
5196
+
5197
+ def message_external_id(message: dict[str, Any]) -> str:
5198
+ for key in external_id_keys:
5199
+ value = message.get(key)
5200
+ if isinstance(value, str) and value.strip():
5201
+ return value.strip()
5202
+ metadata = message.get("metadata")
5203
+ if isinstance(metadata, dict):
5204
+ for key in external_id_keys:
5205
+ value = metadata.get(key)
5206
+ if isinstance(value, str) and value.strip():
5207
+ return value.strip()
5208
+ return ""
5209
+
5210
+ def messages_with_stable_external_ids(
5211
+ raw_messages: list[dict[str, Any]],
5212
+ ) -> list[dict[str, Any]]:
5213
+ if not idempotency_prefix:
5214
+ return raw_messages
5215
+ stable_batch_key = f"{idempotency_prefix}:{session_id}"
5216
+ normalized: list[dict[str, Any]] = []
5217
+ for index, message in enumerate(raw_messages):
5218
+ if not isinstance(message, dict):
5219
+ continue
5220
+ message_copy = dict(message)
5221
+ metadata = dict(message_copy.get("metadata") or {})
5222
+ if not message_external_id(message_copy):
5223
+ metadata["external_id"] = f"idem:{stable_batch_key}:{index}"
5224
+ message_copy["metadata"] = metadata
5225
+ normalized.append(message_copy)
5226
+ return normalized
5227
+
5228
+ def post_thread_create(payload: dict[str, Any]) -> tuple[int, dict[str, Any]]:
5229
+ """Create a thread without treating an existing thread as fatal.
5230
+
5231
+ Historical sync is intentionally rerunnable. A previous process may
5232
+ have created the thread and then stopped before finishing the batch, or
5233
+ another sync may race between our existence check and create request.
5234
+ In those cases we should append/deduplicate, not fail the whole session.
5235
+ """
5236
+ url = f"{get_api_url()}/threads"
5237
+
5238
+ def response_payload(response: httpx.Response) -> dict[str, Any]:
5239
+ try:
5240
+ parsed = response.json()
5241
+ return parsed if isinstance(parsed, dict) else {"detail": parsed}
5242
+ except Exception:
5243
+ return {"detail": response.text}
5244
+
5245
+ try:
5246
+ response = api_request("POST", url, json=payload, timeout=60.0)
5247
+ parsed = response_payload(response)
5248
+ if response.status_code == 409 or (
5249
+ response.status_code == 422
5250
+ and "exist" in json_module.dumps(parsed).lower()
5251
+ ):
5252
+ return response.status_code, parsed
5253
+ response.raise_for_status()
5254
+ return response.status_code, parsed
5255
+ except httpx.ConnectError:
5256
+ error = {
5257
+ "error": "connection_failed",
5258
+ "message": f"Cannot connect to {get_api_url()}",
5259
+ }
5260
+ if is_json_mode():
5261
+ output_json(error)
5262
+ else:
5263
+ _print_connection_error()
5264
+ sys.exit(1)
5265
+ except httpx.HTTPStatusError as e:
5266
+ error = {"error": "api_error", "status_code": e.response.status_code}
5267
+ try:
5268
+ error["detail"] = e.response.json().get("detail", "")
5269
+ except Exception:
5270
+ pass
5271
+ if is_json_mode():
5272
+ output_json(error)
5273
+ else:
5274
+ print_error(
5275
+ f"API Error ({e.response.status_code})",
5276
+ error.get("detail", "Request failed"),
5277
+ )
5278
+ sys.exit(1)
5279
+
5280
+ thread_id = thread_data["thread_id"]
5281
+ messages = messages_with_stable_external_ids(thread_data["messages"])
5282
+ metadata = dict(thread_data.get("metadata") or {})
5283
+ if metadata_updates:
5284
+ metadata.update(metadata_updates)
5285
+
5286
+ existing_params = {"space_id": space_id} if space_id else None
5287
+ existing_thread = api_get_optional(
5288
+ f"/threads/{quote(thread_id, safe='')}",
5289
+ params=existing_params,
5290
+ )
5291
+ if existing_thread is None:
5292
+ payload = {
5293
+ "thread_id": thread_id,
5294
+ "title": thread_data["title"],
5295
+ "messages": messages,
5296
+ "source": thread_data.get("source", source_app),
5297
+ "project": thread_data.get("project"),
5298
+ "workspace": thread_data.get("workspace"),
5299
+ "metadata": metadata,
5300
+ }
5301
+ if space_id:
5302
+ payload["space_id"] = space_id
5303
+ create_status, create_data = post_thread_create(payload)
5304
+ if create_status not in {409, 422}:
5305
+ return {
5306
+ "action": "created",
5307
+ "session_id": session_id,
5308
+ "thread_id": thread_id,
5309
+ "message_count": len(create_data.get("messages", [])) or len(messages),
5310
+ "file": str(session_file),
5311
+ }
5312
+
5313
+ append_payload: dict[str, Any] = {
5314
+ "messages": messages,
5315
+ "deduplicate": True,
5316
+ **({"space_id": space_id} if space_id else {}),
5317
+ }
5318
+ if metadata_updates:
5319
+ append_payload.update(metadata_updates)
5320
+ if idempotency_prefix:
5321
+ append_payload["idempotency_key"] = f"{idempotency_prefix}:{session_id}"
5322
+ append_data = api_post(
5323
+ f"/threads/{quote(thread_id, safe='')}/append",
5324
+ append_payload,
5325
+ )
5326
+ return {
5327
+ "action": "appended",
5328
+ "session_id": session_id,
5329
+ "thread_id": thread_id,
5330
+ "message_count": append_data.get("total_messages", 0),
5331
+ "messages_added": append_data.get("messages_added", 0),
5332
+ "total_messages": append_data.get("total_messages", 0),
5333
+ "file": str(session_file),
5334
+ }
5335
+
5336
+
5337
+ def cmd_threads_sync(
5338
+ client: str,
5339
+ project_path: str | None = None,
5340
+ all_projects: bool = False,
5341
+ apply: bool = False,
5342
+ limit: int | None = None,
5343
+ session_id: str | None = None,
5344
+ truncate: bool = False,
5345
+ space_id: str | None = None,
5346
+ source_roots: list[str] | None = None,
5347
+ ) -> None:
5348
+ """Preview or import historical sessions for a supported local agent host."""
5349
+ if client not in SUPPORTED_SESSION_CLIENTS:
5350
+ data = {
5351
+ "status": "error",
5352
+ "error": "invalid_client",
5353
+ "message": f"Must be one of: {SUPPORTED_SESSION_CLIENTS_TEXT}",
5354
+ }
5355
+ if is_json_mode():
5356
+ output_json(data)
5357
+ else:
5358
+ print_error("Invalid Client", f"'{client}' is not supported", data["message"])
5359
+ sys.exit(1)
5360
+
5361
+ if all_projects and project_path:
5362
+ data = {
5363
+ "status": "error",
5364
+ "error": "conflicting_scope",
5365
+ "message": "Use either --project or --all-projects, not both.",
5366
+ }
5367
+ if is_json_mode():
5368
+ output_json(data)
5369
+ else:
5370
+ print_error("Conflicting Scope", data["message"])
5371
+ sys.exit(1)
5372
+
5373
+ # Pi can safely preview all projects by default. Other clients historically
5374
+ # key discovery by project path, so keep their default scoped to cwd.
5375
+ discovery_path = project_path
5376
+ if all_projects:
5377
+ discovery_path = "__all__"
5378
+ elif discovery_path is None:
5379
+ discovery_path = "*" if client == "pi" else "."
5380
+ resolved_path = (
5381
+ Path(discovery_path).expanduser().absolute()
5382
+ if discovery_path not in {"", "*", "__all__"}
5383
+ else None
5384
+ )
5385
+ if resolved_path is not None and not resolved_path.exists():
5386
+ if is_json_mode():
5387
+ output_json({"error": "path_not_found", "path": str(resolved_path)})
5388
+ else:
5389
+ print_error("Path Not Found", str(resolved_path))
5390
+ sys.exit(1)
5391
+
5392
+ try:
5393
+ sessions = discover_sessions(
5394
+ client,
5395
+ str(resolved_path) if resolved_path is not None else discovery_path,
5396
+ "all",
5397
+ session_id,
5398
+ source_roots=source_roots,
5399
+ )
5400
+ except SessionImportError as exc:
5401
+ data = {
5402
+ "status": "error",
5403
+ "client": client,
5404
+ "project_path": str(resolved_path) if resolved_path is not None else discovery_path,
5405
+ "results": [],
5406
+ "error": exc.error,
5407
+ "hint": exc.hint,
5408
+ }
5409
+ if is_json_mode():
5410
+ output_json(data)
5411
+ else:
5412
+ print_error("Sync Failed", exc.error, exc.hint)
5413
+ sys.exit(1)
5414
+
5415
+ if limit is not None:
5416
+ sessions = sessions[:limit]
5417
+
5418
+ results: list[dict[str, Any]] = []
5419
+ for session in sessions:
5420
+ try:
5421
+ thread_data = parse_session(
5422
+ client,
5423
+ session.file,
5424
+ truncate,
5425
+ session_id=session.session_id,
5426
+ )
5427
+ except Exception as exc:
5428
+ results.append(
5429
+ {
5430
+ "action": "failed",
5431
+ "session_id": session.session_id,
5432
+ "file": str(session.file),
5433
+ "error": str(exc),
5434
+ }
5435
+ )
5436
+ continue
5437
+
5438
+ preview = {
5439
+ "action": "ready",
5440
+ "session_id": session.session_id,
5441
+ "thread_id": thread_data["thread_id"],
5442
+ "title": thread_data["title"],
5443
+ "message_count": len(thread_data.get("messages") or []),
5444
+ "file": str(session.file),
5445
+ "workspace": thread_data.get("workspace"),
5446
+ }
5447
+ if not apply:
5448
+ results.append(preview)
5449
+ continue
5450
+
5451
+ try:
5452
+ results.append(
5453
+ _write_thread_from_session_data(
5454
+ thread_data=thread_data,
5455
+ session_id=session.session_id,
5456
+ session_file=session.file,
5457
+ source_app=client,
5458
+ space_id=space_id,
5459
+ metadata_updates={
5460
+ "historical_import": True,
5461
+ "analysis": "searchable-now-distill-on-demand",
5462
+ "sync_reason": "history_sync",
5463
+ },
5464
+ idempotency_prefix=f"{client}:history",
5465
+ )
5466
+ )
5467
+ except SystemExit:
5468
+ raise
5469
+ except Exception as exc:
5470
+ results.append({**preview, "action": "failed", "error": str(exc)})
5471
+
5472
+ data = {
5473
+ "status": "success",
5474
+ "client": client,
5475
+ "apply": apply,
5476
+ "project_path": str(resolved_path) if resolved_path is not None else discovery_path,
5477
+ "count": len(results),
5478
+ "created": sum(1 for r in results if r.get("action") == "created"),
5479
+ "appended": sum(1 for r in results if r.get("action") == "appended"),
5480
+ "failed": sum(1 for r in results if r.get("action") == "failed"),
5481
+ "results": results,
5482
+ }
5483
+ if is_json_mode():
5484
+ output_json(data)
5485
+ return
5486
+
5487
+ if not apply:
5488
+ console.print(
5489
+ f"[bold]Preview[/bold] — {len(results)} {client} session(s). "
5490
+ "No changes made; re-run with [cyan]--apply[/cyan] to import."
5491
+ )
5492
+ for item in results[:20]:
5493
+ if item.get("action") == "failed":
5494
+ console.print(f" [red]![/red] {item.get('file')}: {item.get('error')}")
5495
+ continue
5496
+ console.print(
5497
+ f" [cyan]{item['thread_id']}[/cyan] "
5498
+ f"({item['message_count']} messages) {item.get('title', '')}"
5499
+ )
5500
+ if len(results) > 20:
5501
+ console.print(f" [dim]... {len(results) - 20} more; use --json for full output[/dim]")
5502
+ return
5503
+
5504
+ if data["failed"]:
5505
+ print_error("Sync Finished With Errors", f"{data['failed']} session(s) failed")
5506
+ else:
5507
+ print_success(
5508
+ "Synced",
5509
+ f"{data['created']} created, {data['appended']} updated",
5510
+ )
5511
+
5512
+
5174
5513
  # ═══════════════════════════════════════════════════════════════════════════════
5175
5514
  # Thread Triage & Distillation Commands
5176
5515
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -10246,13 +10585,14 @@ PRIORITY
10246
10585
  # save - Save coding session as thread
10247
10586
  sv = thr_subs.add_parser(
10248
10587
  "save",
10249
- help="Save Claude Code, Codex, Gemini CLI, or OpenCode session as thread",
10588
+ help="Save Claude Code, Codex, Gemini CLI, OpenCode, or Pi session as thread",
10250
10589
  parents=[_space_parent],
10251
10590
  epilog="""examples:
10252
10591
  nmem t save --from claude-code
10253
10592
  nmem t save --from claude-code -p /path/to/project -m all
10254
10593
  nmem t save --from codex --session-id sess_abc123
10255
10594
  nmem t save --from opencode -p /path/to/project
10595
+ nmem t save --from pi -p /path/to/project
10256
10596
  nmem t save --from gemini-cli --summary "Refactored auth module" """,
10257
10597
  formatter_class=argparse.RawDescriptionHelpFormatter,
10258
10598
  )
@@ -10260,8 +10600,8 @@ PRIORITY
10260
10600
  "--from",
10261
10601
  dest="source_app",
10262
10602
  required=True,
10263
- choices=["claude-code", "codex", "gemini-cli", "opencode"],
10264
- help="Source app: claude-code, codex, gemini-cli, or opencode",
10603
+ choices=list(SUPPORTED_SESSION_CLIENTS),
10604
+ help=f"Source app: {SUPPORTED_SESSION_CLIENTS_TEXT}",
10265
10605
  )
10266
10606
  sv.add_argument(
10267
10607
  "-p",
@@ -10288,6 +10628,58 @@ PRIORITY
10288
10628
  help="Truncate large tool results (>10KB)",
10289
10629
  )
10290
10630
 
10631
+ sync = thr_subs.add_parser(
10632
+ "sync",
10633
+ help="Preview or import historical local agent sessions",
10634
+ parents=[_space_parent],
10635
+ epilog="""examples:
10636
+ nmem t sync --from pi
10637
+ nmem t sync --from pi --apply
10638
+ nmem t sync --from codex --all-projects --limit 20
10639
+ nmem t sync --from pi --session-dir ~/.pi/agent/sessions --limit 20
10640
+ nmem t sync --from codex -p /path/to/project --apply""",
10641
+ formatter_class=argparse.RawDescriptionHelpFormatter,
10642
+ )
10643
+ sync.add_argument(
10644
+ "--from",
10645
+ dest="source_app",
10646
+ required=True,
10647
+ choices=list(SUPPORTED_SESSION_CLIENTS),
10648
+ help=f"Source app: {SUPPORTED_SESSION_CLIENTS_TEXT}",
10649
+ )
10650
+ sync.add_argument(
10651
+ "-p",
10652
+ "--project",
10653
+ default=None,
10654
+ help=(
10655
+ "Project directory path. Defaults to all Pi sessions for Pi, "
10656
+ "and current directory for other clients."
10657
+ ),
10658
+ )
10659
+ sync.add_argument(
10660
+ "--all-projects",
10661
+ action="store_true",
10662
+ help="Scan all discoverable local sessions for this host.",
10663
+ )
10664
+ sync.add_argument(
10665
+ "--apply",
10666
+ action="store_true",
10667
+ help="Import sessions. Without this, only preview what would sync.",
10668
+ )
10669
+ sync.add_argument("--limit", type=int, help="Limit sessions after discovery")
10670
+ sync.add_argument("--session-id", help="Specific session ID")
10671
+ sync.add_argument(
10672
+ "--session-dir",
10673
+ dest="session_dirs",
10674
+ action="append",
10675
+ help="Pi session directory or JSONL file to scan. May be repeated.",
10676
+ )
10677
+ sync.add_argument(
10678
+ "--truncate",
10679
+ action="store_true",
10680
+ help="Truncate large tool results where supported",
10681
+ )
10682
+
10291
10683
  # triage - Check if conversation is worth distilling
10292
10684
  tr = thr_subs.add_parser(
10293
10685
  "triage",
@@ -11429,6 +11821,18 @@ def main() -> int:
11429
11821
  truncate=getattr(args, "truncate", False),
11430
11822
  space_id=getattr(args, "space", None),
11431
11823
  )
11824
+ elif action == "sync":
11825
+ cmd_threads_sync(
11826
+ client=args.source_app,
11827
+ project_path=getattr(args, "project", None),
11828
+ all_projects=getattr(args, "all_projects", False),
11829
+ apply=getattr(args, "apply", False),
11830
+ limit=getattr(args, "limit", None),
11831
+ session_id=getattr(args, "session_id", None),
11832
+ truncate=getattr(args, "truncate", False),
11833
+ space_id=getattr(args, "space", None),
11834
+ source_roots=getattr(args, "session_dirs", None),
11835
+ )
11432
11836
  elif action == "triage":
11433
11837
  cmd_threads_triage(
11434
11838
  thread_id=getattr(args, "thread_id", None),