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.
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/PKG-INFO +27 -6
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/README.md +26 -5
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/pyproject.toml +1 -1
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/__init__.py +1 -1
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/cli.py +412 -8
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/session_import.py +516 -131
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/.gitignore +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/agent_profiles.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/claude_paths.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/data_transfer_paths.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/guidance_rules.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/kfs_cli.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/license_payload.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/memories_reclassify.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/memory_relations_cli.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/py.typed +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/__init__.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/__main__.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/api_client.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/app.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/__init__.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/dashboard.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/graph.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/help.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/memories.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/memory_detail.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/settings.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/thread_detail.py +0 -0
- {nmem_cli-0.9.0 → nmem_cli-0.9.3}/src/nmem_cli/tui/screens/threads.py +0 -0
- {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.
|
|
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,
|
|
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
|
|
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`,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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`,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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=
|
|
10264
|
-
help="Source app:
|
|
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),
|