nmem-cli 0.9.3__tar.gz → 0.9.7__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.3 → nmem_cli-0.9.7}/PKG-INFO +25 -8
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/README.md +24 -7
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/pyproject.toml +1 -1
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/__init__.py +1 -1
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/cli.py +23 -11
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/session_import.py +674 -3
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/tui/screens/settings.py +6 -3
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/.gitignore +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/agent_profiles.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/claude_paths.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/data_transfer_paths.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/guidance_rules.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/kfs_cli.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/license_payload.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/memories_reclassify.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/memory_relations_cli.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/py.typed +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/tui/__init__.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/tui/__main__.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/tui/api_client.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/tui/app.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/tui/screens/__init__.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/tui/screens/dashboard.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/tui/screens/graph.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/tui/screens/help.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/tui/screens/memories.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/tui/screens/memory_detail.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/tui/screens/thread_detail.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/src/nmem_cli/tui/screens/threads.py +0 -0
- {nmem_cli-0.9.3 → nmem_cli-0.9.7}/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.7
|
|
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/
|
|
@@ -118,10 +118,14 @@ nmem t
|
|
|
118
118
|
| `nmem t append <id> -m '[{"role":"user","content":"..."}]'` | Append messages to a thread |
|
|
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
|
+
| `nmem t save --from cursor` | Save the latest Cursor Agent session for this project |
|
|
121
122
|
| `nmem t save --from gemini-cli` | Save Gemini CLI session as thread |
|
|
122
123
|
| `nmem t sync --from codex --all-projects` | Preview historical Codex sessions across projects |
|
|
124
|
+
| `nmem t sync --from cursor --all-projects` | Preview historical Cursor Agent sessions |
|
|
123
125
|
| `nmem t sync --from pi` | Preview historical Pi sessions |
|
|
124
126
|
| `nmem t sync --from pi --apply` | Import historical Pi sessions |
|
|
127
|
+
| `nmem t sync --from hermes` | Preview historical Hermes sessions from `~/.hermes/state.db` |
|
|
128
|
+
| `nmem t sync --from hermes --apply` | Import historical Hermes sessions |
|
|
125
129
|
| `nmem t delete <id>` | Delete a thread |
|
|
126
130
|
| `nmem t delete <id> --dry-run` | Preview what would be deleted |
|
|
127
131
|
|
|
@@ -350,7 +354,7 @@ nmem t append openclaw-session-abc123 \
|
|
|
350
354
|
|
|
351
355
|
### Saving AI Coding Sessions
|
|
352
356
|
|
|
353
|
-
Import conversations from Claude Code, Codex, Gemini CLI, OpenCode, or Pi as threads:
|
|
357
|
+
Import conversations from Claude Code, Codex, Cursor, Gemini CLI, OpenCode, or Pi as threads:
|
|
354
358
|
|
|
355
359
|
```bash
|
|
356
360
|
# Save current Claude Code session (uses current directory)
|
|
@@ -365,6 +369,9 @@ nmem t save --from claude-code -m all
|
|
|
365
369
|
# Save Codex session with a summary
|
|
366
370
|
nmem t save --from codex -s "Implemented auth feature"
|
|
367
371
|
|
|
372
|
+
# Save latest Cursor Agent session for this project
|
|
373
|
+
nmem t save --from cursor
|
|
374
|
+
|
|
368
375
|
# Save Gemini CLI session from the current project
|
|
369
376
|
nmem t save --from gemini-cli
|
|
370
377
|
|
|
@@ -372,8 +379,18 @@ nmem t save --from gemini-cli
|
|
|
372
379
|
nmem t sync --from pi
|
|
373
380
|
nmem t sync --from pi --apply
|
|
374
381
|
|
|
382
|
+
# Preview older Hermes sessions from ~/.hermes/state.db, then import them
|
|
383
|
+
nmem t sync --from hermes
|
|
384
|
+
nmem t sync --from hermes --apply
|
|
385
|
+
|
|
386
|
+
# Preview older Cursor Agent sessions, then import them
|
|
387
|
+
nmem t sync --from cursor --all-projects
|
|
388
|
+
nmem t sync --from cursor --all-projects --apply
|
|
389
|
+
nmem t sync --from cursor --session-dir ~/.cursor/projects/Users-me-project/agent-transcripts --apply
|
|
390
|
+
|
|
375
391
|
# Preview older sessions across all projects for hosts with project-scoped storage
|
|
376
392
|
nmem t sync --from codex --all-projects --limit 20
|
|
393
|
+
nmem t sync --from cursor --all-projects --limit 20
|
|
377
394
|
nmem t sync --from claude-code --all-projects --limit 20
|
|
378
395
|
nmem t sync --from gemini-cli --all-projects --limit 20
|
|
379
396
|
nmem t sync --from opencode --all-projects --limit 20
|
|
@@ -385,29 +402,29 @@ How it works:
|
|
|
385
402
|
- it parses those transcripts into normalized thread messages
|
|
386
403
|
- it then uploads the resulting thread data to your configured Mem server
|
|
387
404
|
|
|
388
|
-
By default, Claude Code, Codex,
|
|
405
|
+
By default, Claude Code, Codex, Cursor, Pi, and Hermes are discovered from `~/.claude`, `~/.codex`, `~/.cursor/projects`, `~/.pi/agent`, and `~/.hermes/state.db`. If you keep them somewhere else, `nmem` also respects `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and `HERMES_HOME` automatically.
|
|
389
406
|
|
|
390
|
-
That means `nmem t save --from ...`
|
|
407
|
+
That means `nmem t save --from ...` and `nmem t sync --from ...` work correctly with remote Mem too: the server does not need direct access to those local agent directories on your laptop.
|
|
391
408
|
|
|
392
409
|
Options:
|
|
393
|
-
- `--from`: Source app (`claude-code`, `codex`, `gemini-cli`, `opencode`, or `
|
|
410
|
+
- `--from`: Source app (`claude-code`, `codex`, `cursor`, `gemini-cli`, `opencode`, `pi`, or `hermes`) - required
|
|
394
411
|
- `-p, --project`: Project directory (default: current dir)
|
|
395
412
|
- `-m, --mode`: `current` (latest session) or `all` (all sessions)
|
|
396
413
|
- `-s, --summary`: Brief session summary
|
|
397
414
|
- `--session-id`: Specific session ID
|
|
398
415
|
- `--truncate`: Truncate large tool results (>10KB)
|
|
399
416
|
|
|
400
|
-
Use `nmem t sync --from ...` for deliberate historical backfills. It previews by default and requires `--apply` before writing. For Pi, `sync` scans all
|
|
417
|
+
Use `nmem t sync --from ...` for deliberate historical backfills. It previews by default and requires `--apply` before writing. For Pi and Hermes, `sync` scans all local sessions by default; pass `--session-dir` to point at a Cursor `agent-transcripts` folder, a Cursor Agent JSONL file, a Pi sessions folder, a Pi JSONL file, a Hermes home folder, or a Hermes `state.db`. For Claude Code, Codex, Cursor, Gemini CLI, and OpenCode, the default stays scoped to the current project; add `--all-projects` when you intentionally want a broader import.
|
|
401
418
|
|
|
402
419
|
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
420
|
|
|
404
421
|
This import path is distinct from desktop auto-sync and watcher-based ingestion:
|
|
405
422
|
|
|
406
|
-
- desktop auto-sync watches transcript
|
|
423
|
+
- desktop auto-sync watches supported local transcript stores on the same machine as the Mem server
|
|
407
424
|
- 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
425
|
- 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
|
|
409
426
|
|
|
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.
|
|
427
|
+
Use the client-side path for remote Mem. A remote Mem server cannot scan your laptop's `~/.codex`, `~/.claude`, `~/.gemini`, `~/.pi`, `~/.hermes/state.db`, or OpenCode session database by itself.
|
|
411
428
|
|
|
412
429
|
## Related
|
|
413
430
|
|
|
@@ -85,10 +85,14 @@ nmem t
|
|
|
85
85
|
| `nmem t append <id> -m '[{"role":"user","content":"..."}]'` | Append messages to a thread |
|
|
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
|
+
| `nmem t save --from cursor` | Save the latest Cursor Agent session for this project |
|
|
88
89
|
| `nmem t save --from gemini-cli` | Save Gemini CLI session as thread |
|
|
89
90
|
| `nmem t sync --from codex --all-projects` | Preview historical Codex sessions across projects |
|
|
91
|
+
| `nmem t sync --from cursor --all-projects` | Preview historical Cursor Agent sessions |
|
|
90
92
|
| `nmem t sync --from pi` | Preview historical Pi sessions |
|
|
91
93
|
| `nmem t sync --from pi --apply` | Import historical Pi sessions |
|
|
94
|
+
| `nmem t sync --from hermes` | Preview historical Hermes sessions from `~/.hermes/state.db` |
|
|
95
|
+
| `nmem t sync --from hermes --apply` | Import historical Hermes sessions |
|
|
92
96
|
| `nmem t delete <id>` | Delete a thread |
|
|
93
97
|
| `nmem t delete <id> --dry-run` | Preview what would be deleted |
|
|
94
98
|
|
|
@@ -317,7 +321,7 @@ nmem t append openclaw-session-abc123 \
|
|
|
317
321
|
|
|
318
322
|
### Saving AI Coding Sessions
|
|
319
323
|
|
|
320
|
-
Import conversations from Claude Code, Codex, Gemini CLI, OpenCode, or Pi as threads:
|
|
324
|
+
Import conversations from Claude Code, Codex, Cursor, Gemini CLI, OpenCode, or Pi as threads:
|
|
321
325
|
|
|
322
326
|
```bash
|
|
323
327
|
# Save current Claude Code session (uses current directory)
|
|
@@ -332,6 +336,9 @@ nmem t save --from claude-code -m all
|
|
|
332
336
|
# Save Codex session with a summary
|
|
333
337
|
nmem t save --from codex -s "Implemented auth feature"
|
|
334
338
|
|
|
339
|
+
# Save latest Cursor Agent session for this project
|
|
340
|
+
nmem t save --from cursor
|
|
341
|
+
|
|
335
342
|
# Save Gemini CLI session from the current project
|
|
336
343
|
nmem t save --from gemini-cli
|
|
337
344
|
|
|
@@ -339,8 +346,18 @@ nmem t save --from gemini-cli
|
|
|
339
346
|
nmem t sync --from pi
|
|
340
347
|
nmem t sync --from pi --apply
|
|
341
348
|
|
|
349
|
+
# Preview older Hermes sessions from ~/.hermes/state.db, then import them
|
|
350
|
+
nmem t sync --from hermes
|
|
351
|
+
nmem t sync --from hermes --apply
|
|
352
|
+
|
|
353
|
+
# Preview older Cursor Agent sessions, then import them
|
|
354
|
+
nmem t sync --from cursor --all-projects
|
|
355
|
+
nmem t sync --from cursor --all-projects --apply
|
|
356
|
+
nmem t sync --from cursor --session-dir ~/.cursor/projects/Users-me-project/agent-transcripts --apply
|
|
357
|
+
|
|
342
358
|
# Preview older sessions across all projects for hosts with project-scoped storage
|
|
343
359
|
nmem t sync --from codex --all-projects --limit 20
|
|
360
|
+
nmem t sync --from cursor --all-projects --limit 20
|
|
344
361
|
nmem t sync --from claude-code --all-projects --limit 20
|
|
345
362
|
nmem t sync --from gemini-cli --all-projects --limit 20
|
|
346
363
|
nmem t sync --from opencode --all-projects --limit 20
|
|
@@ -352,29 +369,29 @@ How it works:
|
|
|
352
369
|
- it parses those transcripts into normalized thread messages
|
|
353
370
|
- it then uploads the resulting thread data to your configured Mem server
|
|
354
371
|
|
|
355
|
-
By default, Claude Code, Codex,
|
|
372
|
+
By default, Claude Code, Codex, Cursor, Pi, and Hermes are discovered from `~/.claude`, `~/.codex`, `~/.cursor/projects`, `~/.pi/agent`, and `~/.hermes/state.db`. If you keep them somewhere else, `nmem` also respects `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and `HERMES_HOME` automatically.
|
|
356
373
|
|
|
357
|
-
That means `nmem t save --from ...`
|
|
374
|
+
That means `nmem t save --from ...` and `nmem t sync --from ...` work correctly with remote Mem too: the server does not need direct access to those local agent directories on your laptop.
|
|
358
375
|
|
|
359
376
|
Options:
|
|
360
|
-
- `--from`: Source app (`claude-code`, `codex`, `gemini-cli`, `opencode`, or `
|
|
377
|
+
- `--from`: Source app (`claude-code`, `codex`, `cursor`, `gemini-cli`, `opencode`, `pi`, or `hermes`) - required
|
|
361
378
|
- `-p, --project`: Project directory (default: current dir)
|
|
362
379
|
- `-m, --mode`: `current` (latest session) or `all` (all sessions)
|
|
363
380
|
- `-s, --summary`: Brief session summary
|
|
364
381
|
- `--session-id`: Specific session ID
|
|
365
382
|
- `--truncate`: Truncate large tool results (>10KB)
|
|
366
383
|
|
|
367
|
-
Use `nmem t sync --from ...` for deliberate historical backfills. It previews by default and requires `--apply` before writing. For Pi, `sync` scans all
|
|
384
|
+
Use `nmem t sync --from ...` for deliberate historical backfills. It previews by default and requires `--apply` before writing. For Pi and Hermes, `sync` scans all local sessions by default; pass `--session-dir` to point at a Cursor `agent-transcripts` folder, a Cursor Agent JSONL file, a Pi sessions folder, a Pi JSONL file, a Hermes home folder, or a Hermes `state.db`. For Claude Code, Codex, Cursor, Gemini CLI, and OpenCode, the default stays scoped to the current project; add `--all-projects` when you intentionally want a broader import.
|
|
368
385
|
|
|
369
386
|
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
387
|
|
|
371
388
|
This import path is distinct from desktop auto-sync and watcher-based ingestion:
|
|
372
389
|
|
|
373
|
-
- desktop auto-sync watches transcript
|
|
390
|
+
- desktop auto-sync watches supported local transcript stores on the same machine as the Mem server
|
|
374
391
|
- 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
392
|
- 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
|
|
376
393
|
|
|
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.
|
|
394
|
+
Use the client-side path for remote Mem. A remote Mem server cannot scan your laptop's `~/.codex`, `~/.claude`, `~/.gemini`, `~/.pi`, `~/.hermes/state.db`, or OpenCode session database by itself.
|
|
378
395
|
|
|
379
396
|
## Related
|
|
380
397
|
|
|
@@ -87,6 +87,10 @@ SUPPORTED_PROVIDER_TYPES = [
|
|
|
87
87
|
"openai_compatible",
|
|
88
88
|
]
|
|
89
89
|
|
|
90
|
+
DEFAULT_MAX_TOKENS_PER_HOUR = 5_000_000
|
|
91
|
+
DEFAULT_MAX_TOKENS_PER_DAY = 10_000_000
|
|
92
|
+
DEFAULT_MAX_TOKENS_PER_TASK = 2_000_000
|
|
93
|
+
|
|
90
94
|
PROVIDER_OPTIONS = [
|
|
91
95
|
("OpenAI (`openai`)", "openai"),
|
|
92
96
|
("Anthropic (`anthropic`)", "anthropic"),
|
|
@@ -5370,13 +5374,14 @@ def cmd_threads_sync(
|
|
|
5370
5374
|
print_error("Conflicting Scope", data["message"])
|
|
5371
5375
|
sys.exit(1)
|
|
5372
5376
|
|
|
5373
|
-
# Pi
|
|
5374
|
-
#
|
|
5377
|
+
# Pi and Hermes have one local session store, so they can safely preview all
|
|
5378
|
+
# sessions by default. Cursor stays cwd-scoped unless the caller gives an
|
|
5379
|
+
# explicit transcript root; then the root itself is the user's selected scope.
|
|
5375
5380
|
discovery_path = project_path
|
|
5376
5381
|
if all_projects:
|
|
5377
5382
|
discovery_path = "__all__"
|
|
5378
5383
|
elif discovery_path is None:
|
|
5379
|
-
discovery_path = "*" if client
|
|
5384
|
+
discovery_path = "*" if client in {"pi", "hermes"} or (client == "cursor" and source_roots) else "."
|
|
5380
5385
|
resolved_path = (
|
|
5381
5386
|
Path(discovery_path).expanduser().absolute()
|
|
5382
5387
|
if discovery_path not in {"", "*", "__all__"}
|
|
@@ -9218,9 +9223,9 @@ def cmd_config_settings_show() -> None:
|
|
|
9218
9223
|
console.print(f" KG Extraction Confidence: {settings.get('kgExtractionConfidence', 0.7)}")
|
|
9219
9224
|
|
|
9220
9225
|
# Token budget
|
|
9221
|
-
max_hour = settings.get("maxTokensPerHour",
|
|
9222
|
-
max_day = settings.get("maxTokensPerDay",
|
|
9223
|
-
max_task = settings.get("maxTokensPerTask",
|
|
9226
|
+
max_hour = settings.get("maxTokensPerHour", DEFAULT_MAX_TOKENS_PER_HOUR)
|
|
9227
|
+
max_day = settings.get("maxTokensPerDay", DEFAULT_MAX_TOKENS_PER_DAY)
|
|
9228
|
+
max_task = settings.get("maxTokensPerTask", DEFAULT_MAX_TOKENS_PER_TASK)
|
|
9224
9229
|
console.print(f" Max Tokens/Hour: {_format_tokens(max_hour)}")
|
|
9225
9230
|
console.print(f" Max Tokens/Day: {_format_tokens(max_day)}")
|
|
9226
9231
|
console.print(f" Max Tokens/Task: {_format_tokens(max_task)}")
|
|
@@ -10585,12 +10590,13 @@ PRIORITY
|
|
|
10585
10590
|
# save - Save coding session as thread
|
|
10586
10591
|
sv = thr_subs.add_parser(
|
|
10587
10592
|
"save",
|
|
10588
|
-
help="Save Claude Code, Codex, Gemini CLI, OpenCode, or Pi session as thread",
|
|
10593
|
+
help="Save Claude Code, Codex, Cursor, Gemini CLI, OpenCode, or Pi session as thread",
|
|
10589
10594
|
parents=[_space_parent],
|
|
10590
10595
|
epilog="""examples:
|
|
10591
10596
|
nmem t save --from claude-code
|
|
10592
10597
|
nmem t save --from claude-code -p /path/to/project -m all
|
|
10593
10598
|
nmem t save --from codex --session-id sess_abc123
|
|
10599
|
+
nmem t save --from cursor -p /path/to/project
|
|
10594
10600
|
nmem t save --from opencode -p /path/to/project
|
|
10595
10601
|
nmem t save --from pi -p /path/to/project
|
|
10596
10602
|
nmem t save --from gemini-cli --summary "Refactored auth module" """,
|
|
@@ -10635,8 +10641,11 @@ PRIORITY
|
|
|
10635
10641
|
epilog="""examples:
|
|
10636
10642
|
nmem t sync --from pi
|
|
10637
10643
|
nmem t sync --from pi --apply
|
|
10644
|
+
nmem t sync --from cursor --all-projects --limit 20
|
|
10645
|
+
nmem t sync --from hermes --apply
|
|
10638
10646
|
nmem t sync --from codex --all-projects --limit 20
|
|
10639
|
-
nmem t sync --from
|
|
10647
|
+
nmem t sync --from cursor --session-dir ~/.cursor/projects/Users-me-project/agent-transcripts --apply
|
|
10648
|
+
nmem t sync --from hermes --session-dir ~/.hermes/state.db --limit 20
|
|
10640
10649
|
nmem t sync --from codex -p /path/to/project --apply""",
|
|
10641
10650
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
10642
10651
|
)
|
|
@@ -10652,8 +10661,8 @@ PRIORITY
|
|
|
10652
10661
|
"--project",
|
|
10653
10662
|
default=None,
|
|
10654
10663
|
help=(
|
|
10655
|
-
"Project directory path. Defaults to all Pi sessions for
|
|
10656
|
-
"and current directory for other clients."
|
|
10664
|
+
"Project directory path. Defaults to all Pi/Hermes sessions for "
|
|
10665
|
+
"Pi and Hermes, and current directory for other clients."
|
|
10657
10666
|
),
|
|
10658
10667
|
)
|
|
10659
10668
|
sync.add_argument(
|
|
@@ -10672,7 +10681,10 @@ PRIORITY
|
|
|
10672
10681
|
"--session-dir",
|
|
10673
10682
|
dest="session_dirs",
|
|
10674
10683
|
action="append",
|
|
10675
|
-
help=
|
|
10684
|
+
help=(
|
|
10685
|
+
"Cursor/Pi session directory/JSONL file or Hermes state.db/Hermes home "
|
|
10686
|
+
"to scan. May be repeated."
|
|
10687
|
+
),
|
|
10676
10688
|
)
|
|
10677
10689
|
sync.add_argument(
|
|
10678
10690
|
"--truncate",
|
|
@@ -6,8 +6,9 @@ published separately on PyPI and must remain able to perform remote-safe capture
|
|
|
6
6
|
without importing server internals.
|
|
7
7
|
|
|
8
8
|
Maintenance rule:
|
|
9
|
-
- if Claude Code, Codex, Gemini CLI, OpenCode, or
|
|
10
|
-
formats or storage layout, update this client module and
|
|
9
|
+
- if Claude Code, Codex/Cursor, Gemini CLI, OpenCode, Pi, or Hermes change
|
|
10
|
+
their transcript formats or storage layout, update this client module and
|
|
11
|
+
the corresponding
|
|
11
12
|
server-local parser/import path in the same change
|
|
12
13
|
- keep the normalized thread output aligned across both sides
|
|
13
14
|
"""
|
|
@@ -30,7 +31,15 @@ from .claude_paths import find_valid_claude_path
|
|
|
30
31
|
|
|
31
32
|
logger = logging.getLogger(__name__)
|
|
32
33
|
|
|
33
|
-
SUPPORTED_SESSION_CLIENTS = (
|
|
34
|
+
SUPPORTED_SESSION_CLIENTS = (
|
|
35
|
+
"claude-code",
|
|
36
|
+
"codex",
|
|
37
|
+
"cursor",
|
|
38
|
+
"gemini-cli",
|
|
39
|
+
"opencode",
|
|
40
|
+
"pi",
|
|
41
|
+
"hermes",
|
|
42
|
+
)
|
|
34
43
|
SUPPORTED_SESSION_CLIENTS_TEXT = ", ".join(SUPPORTED_SESSION_CLIENTS)
|
|
35
44
|
_ALL_PROJECT_SENTINELS = {"", "*", "__all__"}
|
|
36
45
|
|
|
@@ -54,6 +63,7 @@ _KNOWN_TITLE_SCAFFOLD_PREFIXES = (
|
|
|
54
63
|
)
|
|
55
64
|
|
|
56
65
|
_KNOWN_HOST_SCAFFOLD_TAG_PREFIXES = (
|
|
66
|
+
"<user_query>",
|
|
57
67
|
"<environment_context>",
|
|
58
68
|
"<permissions instructions>",
|
|
59
69
|
"<apps_instructions>",
|
|
@@ -206,12 +216,16 @@ def discover_sessions(
|
|
|
206
216
|
candidates = _discover_claude_sessions(project_path, session_id)
|
|
207
217
|
elif client == "codex":
|
|
208
218
|
candidates = _discover_codex_sessions(project_path, session_id)
|
|
219
|
+
elif client == "cursor":
|
|
220
|
+
candidates = _discover_cursor_sessions(project_path, session_id, source_roots)
|
|
209
221
|
elif client == "gemini-cli":
|
|
210
222
|
candidates = _discover_gemini_sessions(project_path, session_id)
|
|
211
223
|
elif client == "opencode":
|
|
212
224
|
candidates = _discover_opencode_sessions(project_path, session_id)
|
|
213
225
|
elif client == "pi":
|
|
214
226
|
candidates = _discover_pi_sessions(project_path, session_id, source_roots)
|
|
227
|
+
elif client == "hermes":
|
|
228
|
+
candidates = _discover_hermes_sessions(project_path, session_id, source_roots)
|
|
215
229
|
else:
|
|
216
230
|
raise SessionImportError(
|
|
217
231
|
f"Unsupported client: {client}. Supported: {SUPPORTED_SESSION_CLIENTS_TEXT}"
|
|
@@ -233,12 +247,20 @@ def parse_session(
|
|
|
233
247
|
return parse_claude_code_session_streaming(file_path, truncate_large_content)
|
|
234
248
|
if client == "codex":
|
|
235
249
|
return parse_codex_session_streaming(file_path, truncate_large_content)
|
|
250
|
+
if client == "cursor":
|
|
251
|
+
return parse_cursor_agent_session(file_path)
|
|
236
252
|
if client == "gemini-cli":
|
|
237
253
|
return parse_gemini_session(file_path)
|
|
238
254
|
if client == "opencode":
|
|
239
255
|
return parse_opencode_session(file_path, session_id=session_id)
|
|
240
256
|
if client == "pi":
|
|
241
257
|
return parse_pi_session(file_path)
|
|
258
|
+
if client == "hermes":
|
|
259
|
+
return parse_hermes_session(
|
|
260
|
+
file_path,
|
|
261
|
+
session_id=session_id,
|
|
262
|
+
truncate_large_content=truncate_large_content,
|
|
263
|
+
)
|
|
242
264
|
raise SessionImportError(
|
|
243
265
|
f"Unsupported client: {client}. Supported: {SUPPORTED_SESSION_CLIENTS_TEXT}"
|
|
244
266
|
)
|
|
@@ -409,6 +431,165 @@ def _discover_codex_sessions(
|
|
|
409
431
|
return candidates
|
|
410
432
|
|
|
411
433
|
|
|
434
|
+
def _get_cursor_agent_projects_dir() -> Path:
|
|
435
|
+
return Path.home() / ".cursor" / "projects"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _encode_cursor_project_path(project_path: str) -> str:
|
|
439
|
+
normalized = os.path.normpath(os.path.expanduser(project_path))
|
|
440
|
+
if sys.platform == "win32" and ":\\" in normalized:
|
|
441
|
+
return normalized.replace(":\\", "--").replace("\\", "-")
|
|
442
|
+
return normalized.lstrip("/").replace("/", "-")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _cursor_project_name(project_encoded: str | None) -> str | None:
|
|
446
|
+
if not project_encoded:
|
|
447
|
+
return None
|
|
448
|
+
parts = [part for part in project_encoded.split("-") if part]
|
|
449
|
+
return parts[-1] if parts else project_encoded
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _cursor_project_dir_for_transcript(
|
|
453
|
+
transcript_path: Path,
|
|
454
|
+
projects_root: Path,
|
|
455
|
+
) -> Optional[Path]:
|
|
456
|
+
try:
|
|
457
|
+
relative = transcript_path.relative_to(projects_root)
|
|
458
|
+
except ValueError:
|
|
459
|
+
return None
|
|
460
|
+
if not relative.parts:
|
|
461
|
+
return None
|
|
462
|
+
return projects_root / relative.parts[0]
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _get_cursor_agent_roots(source_roots: Optional[list[str]] = None) -> list[Path]:
|
|
466
|
+
if source_roots:
|
|
467
|
+
roots: list[Path] = []
|
|
468
|
+
for root in source_roots:
|
|
469
|
+
candidate = Path(root).expanduser()
|
|
470
|
+
if candidate.is_file() and candidate.suffix == ".jsonl":
|
|
471
|
+
roots.append(candidate)
|
|
472
|
+
elif candidate.name == "agent-transcripts":
|
|
473
|
+
roots.append(candidate)
|
|
474
|
+
elif (candidate / "agent-transcripts").exists():
|
|
475
|
+
roots.append(candidate / "agent-transcripts")
|
|
476
|
+
else:
|
|
477
|
+
roots.append(candidate)
|
|
478
|
+
return roots
|
|
479
|
+
return [_get_cursor_agent_projects_dir()]
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _iter_cursor_agent_jsonl_files(
|
|
483
|
+
source_roots: Optional[list[str]] = None,
|
|
484
|
+
) -> list[Path]:
|
|
485
|
+
files: list[Path] = []
|
|
486
|
+
for root in _get_cursor_agent_roots(source_roots):
|
|
487
|
+
if root.is_file() and root.suffix == ".jsonl":
|
|
488
|
+
files.append(root)
|
|
489
|
+
elif root.name == "agent-transcripts":
|
|
490
|
+
files.extend(path for path in root.glob("**/*.jsonl") if path.is_file())
|
|
491
|
+
else:
|
|
492
|
+
files.extend(
|
|
493
|
+
path
|
|
494
|
+
for path in root.glob("*/agent-transcripts/**/*.jsonl")
|
|
495
|
+
if path.is_file()
|
|
496
|
+
)
|
|
497
|
+
return files
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _cursor_agent_message_text(content: Any) -> str:
|
|
501
|
+
if isinstance(content, str):
|
|
502
|
+
return content
|
|
503
|
+
if not isinstance(content, list):
|
|
504
|
+
return ""
|
|
505
|
+
parts: list[str] = []
|
|
506
|
+
for item in content:
|
|
507
|
+
if not isinstance(item, dict):
|
|
508
|
+
continue
|
|
509
|
+
item_type = item.get("type")
|
|
510
|
+
if item_type == "text":
|
|
511
|
+
text = item.get("text")
|
|
512
|
+
if isinstance(text, str) and text.strip():
|
|
513
|
+
parts.append(text)
|
|
514
|
+
elif item_type == "tool_use":
|
|
515
|
+
tool_name = item.get("name") or item.get("tool") or "tool"
|
|
516
|
+
if isinstance(tool_name, str) and tool_name.strip():
|
|
517
|
+
parts.append(f"[Tool use: {tool_name.strip()}]")
|
|
518
|
+
return "\n".join(parts)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _discover_cursor_sessions(
|
|
522
|
+
project_path: str,
|
|
523
|
+
target_session_id: Optional[str],
|
|
524
|
+
source_roots: Optional[list[str]] = None,
|
|
525
|
+
) -> list[SessionCandidate]:
|
|
526
|
+
all_projects = _is_all_projects_path(project_path)
|
|
527
|
+
expected_project_encoded = (
|
|
528
|
+
None if all_projects else _encode_cursor_project_path(project_path)
|
|
529
|
+
)
|
|
530
|
+
projects_root = _get_cursor_agent_projects_dir()
|
|
531
|
+
candidates: list[SessionCandidate] = []
|
|
532
|
+
|
|
533
|
+
for session_file in _iter_cursor_agent_jsonl_files(source_roots):
|
|
534
|
+
project_dir = _cursor_project_dir_for_transcript(session_file, projects_root)
|
|
535
|
+
project_encoded = project_dir.name if project_dir else None
|
|
536
|
+
if expected_project_encoded and project_encoded != expected_project_encoded:
|
|
537
|
+
continue
|
|
538
|
+
|
|
539
|
+
file_session_id = session_file.stem
|
|
540
|
+
if target_session_id and file_session_id != target_session_id:
|
|
541
|
+
continue
|
|
542
|
+
|
|
543
|
+
user_messages = 0
|
|
544
|
+
assistant_messages = 0
|
|
545
|
+
try:
|
|
546
|
+
with open(session_file, "r", encoding="utf-8") as handle:
|
|
547
|
+
for line in handle:
|
|
548
|
+
line = line.strip()
|
|
549
|
+
if not line:
|
|
550
|
+
continue
|
|
551
|
+
try:
|
|
552
|
+
record = json.loads(line)
|
|
553
|
+
except json.JSONDecodeError:
|
|
554
|
+
continue
|
|
555
|
+
if not isinstance(record, dict):
|
|
556
|
+
continue
|
|
557
|
+
role = record.get("role")
|
|
558
|
+
message = record.get("message")
|
|
559
|
+
content = (
|
|
560
|
+
message.get("content") if isinstance(message, dict) else None
|
|
561
|
+
)
|
|
562
|
+
if not _cursor_agent_message_text(content).strip():
|
|
563
|
+
continue
|
|
564
|
+
if role == "user":
|
|
565
|
+
user_messages += 1
|
|
566
|
+
elif role == "assistant":
|
|
567
|
+
assistant_messages += 1
|
|
568
|
+
except Exception as exc:
|
|
569
|
+
logger.warning(
|
|
570
|
+
"Failed to analyze Cursor Agent transcript %s: %s",
|
|
571
|
+
session_file,
|
|
572
|
+
exc,
|
|
573
|
+
)
|
|
574
|
+
continue
|
|
575
|
+
|
|
576
|
+
if user_messages + assistant_messages <= 0:
|
|
577
|
+
continue
|
|
578
|
+
|
|
579
|
+
candidates.append(
|
|
580
|
+
SessionCandidate(
|
|
581
|
+
file=session_file,
|
|
582
|
+
session_id=file_session_id,
|
|
583
|
+
sort_key=session_file.stat().st_mtime,
|
|
584
|
+
user_messages=user_messages,
|
|
585
|
+
assistant_messages=assistant_messages,
|
|
586
|
+
)
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
candidates.sort(key=lambda s: (s.sort_key, s.file.stat().st_mtime), reverse=True)
|
|
590
|
+
return candidates
|
|
591
|
+
|
|
592
|
+
|
|
412
593
|
def _discover_gemini_sessions(
|
|
413
594
|
project_path: str, target_session_id: Optional[str]
|
|
414
595
|
) -> list[SessionCandidate]:
|
|
@@ -699,6 +880,31 @@ def _get_pi_session_roots(source_roots: Optional[list[str]] = None) -> list[Path
|
|
|
699
880
|
return roots
|
|
700
881
|
|
|
701
882
|
|
|
883
|
+
def _get_hermes_db_paths(source_roots: Optional[list[str]] = None) -> list[Path]:
|
|
884
|
+
paths: list[Path] = []
|
|
885
|
+
|
|
886
|
+
def add(candidate: str | Path | None) -> None:
|
|
887
|
+
if candidate is None:
|
|
888
|
+
return
|
|
889
|
+
path = Path(candidate).expanduser()
|
|
890
|
+
if path.is_dir():
|
|
891
|
+
path = path / "state.db"
|
|
892
|
+
if path not in paths:
|
|
893
|
+
paths.append(path)
|
|
894
|
+
|
|
895
|
+
if source_roots:
|
|
896
|
+
for root in source_roots:
|
|
897
|
+
add(root)
|
|
898
|
+
return paths
|
|
899
|
+
|
|
900
|
+
hermes_home = os.environ.get("HERMES_HOME", "").strip()
|
|
901
|
+
if hermes_home:
|
|
902
|
+
add(Path(hermes_home) / "state.db")
|
|
903
|
+
|
|
904
|
+
add(Path.home() / ".hermes" / "state.db")
|
|
905
|
+
return paths
|
|
906
|
+
|
|
907
|
+
|
|
702
908
|
def _iter_pi_jsonl_files(source_roots: Optional[list[str]] = None) -> list[Path]:
|
|
703
909
|
seen: set[Path] = set()
|
|
704
910
|
files: list[Path] = []
|
|
@@ -825,6 +1031,91 @@ def _discover_pi_sessions(
|
|
|
825
1031
|
return candidates
|
|
826
1032
|
|
|
827
1033
|
|
|
1034
|
+
def _safe_hermes_sqlite_connect(db_path: Path) -> Optional[sqlite3.Connection]:
|
|
1035
|
+
max_retries = 3
|
|
1036
|
+
delays = [0.1, 0.2, 0.4]
|
|
1037
|
+
|
|
1038
|
+
for attempt in range(max_retries):
|
|
1039
|
+
try:
|
|
1040
|
+
conn = sqlite3.connect(
|
|
1041
|
+
f"file:{db_path}?mode=ro",
|
|
1042
|
+
timeout=5,
|
|
1043
|
+
uri=True,
|
|
1044
|
+
)
|
|
1045
|
+
conn.row_factory = sqlite3.Row
|
|
1046
|
+
return conn
|
|
1047
|
+
except sqlite3.OperationalError as exc:
|
|
1048
|
+
if "database is locked" in str(exc) and attempt < max_retries - 1:
|
|
1049
|
+
time.sleep(delays[attempt])
|
|
1050
|
+
continue
|
|
1051
|
+
logger.warning("Failed to connect to Hermes database %s: %s", db_path, exc)
|
|
1052
|
+
return None
|
|
1053
|
+
except Exception as exc:
|
|
1054
|
+
logger.warning(
|
|
1055
|
+
"Unexpected error connecting to Hermes database %s: %s",
|
|
1056
|
+
db_path,
|
|
1057
|
+
exc,
|
|
1058
|
+
)
|
|
1059
|
+
return None
|
|
1060
|
+
|
|
1061
|
+
return None
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _discover_hermes_sessions(
|
|
1065
|
+
project_path: str,
|
|
1066
|
+
target_session_id: Optional[str],
|
|
1067
|
+
source_roots: Optional[list[str]] = None,
|
|
1068
|
+
) -> list[SessionCandidate]:
|
|
1069
|
+
del project_path
|
|
1070
|
+
candidates: list[SessionCandidate] = []
|
|
1071
|
+
seen_session_ids: set[str] = set()
|
|
1072
|
+
|
|
1073
|
+
for db_path in _get_hermes_db_paths(source_roots):
|
|
1074
|
+
if not db_path.exists() or not db_path.is_file():
|
|
1075
|
+
continue
|
|
1076
|
+
conn = _safe_hermes_sqlite_connect(db_path)
|
|
1077
|
+
if conn is None:
|
|
1078
|
+
continue
|
|
1079
|
+
try:
|
|
1080
|
+
cursor = conn.cursor()
|
|
1081
|
+
cursor.execute(
|
|
1082
|
+
"""
|
|
1083
|
+
SELECT id, started_at, ended_at, message_count
|
|
1084
|
+
FROM sessions
|
|
1085
|
+
WHERE (? IS NULL OR id = ?)
|
|
1086
|
+
ORDER BY COALESCE(ended_at, started_at) DESC
|
|
1087
|
+
""",
|
|
1088
|
+
(target_session_id, target_session_id),
|
|
1089
|
+
)
|
|
1090
|
+
for row in cursor.fetchall():
|
|
1091
|
+
session_id = str(row["id"] or "")
|
|
1092
|
+
if not session_id or session_id in seen_session_ids:
|
|
1093
|
+
continue
|
|
1094
|
+
|
|
1095
|
+
message_count = int(row["message_count"] or 0)
|
|
1096
|
+
if message_count <= 0:
|
|
1097
|
+
continue
|
|
1098
|
+
|
|
1099
|
+
sort_key = float(row["ended_at"] or row["started_at"] or 0.0)
|
|
1100
|
+
candidates.append(
|
|
1101
|
+
SessionCandidate(
|
|
1102
|
+
file=db_path,
|
|
1103
|
+
session_id=session_id,
|
|
1104
|
+
sort_key=sort_key,
|
|
1105
|
+
user_messages=message_count,
|
|
1106
|
+
)
|
|
1107
|
+
)
|
|
1108
|
+
seen_session_ids.add(session_id)
|
|
1109
|
+
except sqlite3.Error as exc:
|
|
1110
|
+
logger.warning("Failed to discover Hermes sessions in %s: %s", db_path, exc)
|
|
1111
|
+
continue
|
|
1112
|
+
finally:
|
|
1113
|
+
conn.close()
|
|
1114
|
+
|
|
1115
|
+
candidates.sort(key=lambda s: (s.sort_key, s.file.stat().st_mtime), reverse=True)
|
|
1116
|
+
return candidates
|
|
1117
|
+
|
|
1118
|
+
|
|
828
1119
|
def _resolve_gemini_chats_dir(project_path: str) -> Optional[Path]:
|
|
829
1120
|
dirs = _resolve_gemini_chats_dirs(project_path)
|
|
830
1121
|
return dirs[0] if dirs else None
|
|
@@ -1014,6 +1305,89 @@ def parse_codex_session_streaming(
|
|
|
1014
1305
|
}
|
|
1015
1306
|
|
|
1016
1307
|
|
|
1308
|
+
def parse_cursor_agent_session(file_path: Path) -> dict[str, Any]:
|
|
1309
|
+
messages: list[dict[str, Any]] = []
|
|
1310
|
+
first_user_content: Optional[str] = None
|
|
1311
|
+
session_id = file_path.stem
|
|
1312
|
+
projects_root = _get_cursor_agent_projects_dir()
|
|
1313
|
+
project_dir = _cursor_project_dir_for_transcript(file_path, projects_root)
|
|
1314
|
+
project_encoded = project_dir.name if project_dir else None
|
|
1315
|
+
project_name = _cursor_project_name(project_encoded)
|
|
1316
|
+
|
|
1317
|
+
with open(file_path, "r", encoding="utf-8") as handle:
|
|
1318
|
+
for line_num, line in enumerate(handle, 1):
|
|
1319
|
+
line = line.strip()
|
|
1320
|
+
if not line:
|
|
1321
|
+
continue
|
|
1322
|
+
try:
|
|
1323
|
+
record = json.loads(line)
|
|
1324
|
+
except json.JSONDecodeError:
|
|
1325
|
+
logger.warning(
|
|
1326
|
+
"Skipping invalid Cursor Agent JSON on line %s",
|
|
1327
|
+
line_num,
|
|
1328
|
+
)
|
|
1329
|
+
continue
|
|
1330
|
+
if not isinstance(record, dict):
|
|
1331
|
+
continue
|
|
1332
|
+
|
|
1333
|
+
role = record.get("role")
|
|
1334
|
+
if role not in {"user", "assistant"}:
|
|
1335
|
+
continue
|
|
1336
|
+
|
|
1337
|
+
message = record.get("message")
|
|
1338
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
1339
|
+
content_text = _cursor_agent_message_text(content).strip()
|
|
1340
|
+
if not content_text:
|
|
1341
|
+
continue
|
|
1342
|
+
|
|
1343
|
+
if (
|
|
1344
|
+
role == "user"
|
|
1345
|
+
and not first_user_content
|
|
1346
|
+
and not _is_poor_title_source(content_text)
|
|
1347
|
+
):
|
|
1348
|
+
first_user_content = content_text
|
|
1349
|
+
|
|
1350
|
+
messages.append(
|
|
1351
|
+
{
|
|
1352
|
+
"role": role,
|
|
1353
|
+
"content": content_text,
|
|
1354
|
+
"timestamp": record.get("timestamp"),
|
|
1355
|
+
"metadata": {
|
|
1356
|
+
"cursor_agent_transcript": True,
|
|
1357
|
+
"line": line_num,
|
|
1358
|
+
},
|
|
1359
|
+
}
|
|
1360
|
+
)
|
|
1361
|
+
|
|
1362
|
+
if not messages:
|
|
1363
|
+
raise SessionImportError(
|
|
1364
|
+
"No valid user/assistant messages found in Cursor Agent transcript"
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
title = "Cursor Agent Session"
|
|
1368
|
+
if first_user_content:
|
|
1369
|
+
title = first_user_content[:60].replace("\n", " ").strip()
|
|
1370
|
+
if len(first_user_content) > 60:
|
|
1371
|
+
title += "..."
|
|
1372
|
+
elif project_name:
|
|
1373
|
+
title = f"Cursor Agent: {project_name}"
|
|
1374
|
+
|
|
1375
|
+
return {
|
|
1376
|
+
"thread_id": f"cursor-{session_id}",
|
|
1377
|
+
"title": title,
|
|
1378
|
+
"messages": messages,
|
|
1379
|
+
"source": "cursor",
|
|
1380
|
+
"workspace": None,
|
|
1381
|
+
"metadata": {
|
|
1382
|
+
"session_id": session_id,
|
|
1383
|
+
"project_encoded": project_encoded,
|
|
1384
|
+
"project_name": project_name,
|
|
1385
|
+
"transcript_format": "cursor_agent_jsonl",
|
|
1386
|
+
"original_file_path": str(file_path),
|
|
1387
|
+
},
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
|
|
1017
1391
|
def parse_gemini_session(file_path: Path) -> dict[str, Any]:
|
|
1018
1392
|
with open(file_path, "r", encoding="utf-8") as handle:
|
|
1019
1393
|
conversation = json.load(handle)
|
|
@@ -1271,6 +1645,303 @@ def parse_pi_session(file_path: Path) -> dict[str, Any]:
|
|
|
1271
1645
|
}
|
|
1272
1646
|
|
|
1273
1647
|
|
|
1648
|
+
def parse_hermes_session(
|
|
1649
|
+
db_path: Path,
|
|
1650
|
+
*,
|
|
1651
|
+
session_id: Optional[str],
|
|
1652
|
+
truncate_large_content: bool = False,
|
|
1653
|
+
) -> dict[str, Any]:
|
|
1654
|
+
if not session_id:
|
|
1655
|
+
raise SessionImportError("Hermes SQLite import requires a session_id")
|
|
1656
|
+
|
|
1657
|
+
conn = _safe_hermes_sqlite_connect(db_path)
|
|
1658
|
+
if conn is None:
|
|
1659
|
+
raise SessionImportError(f"Failed to connect to Hermes database: {db_path}")
|
|
1660
|
+
|
|
1661
|
+
try:
|
|
1662
|
+
cursor = conn.cursor()
|
|
1663
|
+
cursor.execute(
|
|
1664
|
+
"""
|
|
1665
|
+
SELECT
|
|
1666
|
+
id,
|
|
1667
|
+
source,
|
|
1668
|
+
user_id,
|
|
1669
|
+
model,
|
|
1670
|
+
model_config,
|
|
1671
|
+
parent_session_id,
|
|
1672
|
+
started_at,
|
|
1673
|
+
ended_at,
|
|
1674
|
+
end_reason,
|
|
1675
|
+
message_count,
|
|
1676
|
+
tool_call_count,
|
|
1677
|
+
input_tokens,
|
|
1678
|
+
output_tokens,
|
|
1679
|
+
cache_read_tokens,
|
|
1680
|
+
cache_write_tokens,
|
|
1681
|
+
reasoning_tokens,
|
|
1682
|
+
billing_provider,
|
|
1683
|
+
billing_base_url,
|
|
1684
|
+
billing_mode,
|
|
1685
|
+
estimated_cost_usd,
|
|
1686
|
+
actual_cost_usd,
|
|
1687
|
+
cost_status,
|
|
1688
|
+
cost_source,
|
|
1689
|
+
pricing_version,
|
|
1690
|
+
title
|
|
1691
|
+
FROM sessions
|
|
1692
|
+
WHERE id = ?
|
|
1693
|
+
""",
|
|
1694
|
+
(session_id,),
|
|
1695
|
+
)
|
|
1696
|
+
session_row = cursor.fetchone()
|
|
1697
|
+
if session_row is None:
|
|
1698
|
+
raise SessionImportError(
|
|
1699
|
+
f"Session not found in Hermes database: {session_id}"
|
|
1700
|
+
)
|
|
1701
|
+
|
|
1702
|
+
cursor.execute(
|
|
1703
|
+
"""
|
|
1704
|
+
SELECT
|
|
1705
|
+
id,
|
|
1706
|
+
role,
|
|
1707
|
+
content,
|
|
1708
|
+
tool_call_id,
|
|
1709
|
+
tool_calls,
|
|
1710
|
+
tool_name,
|
|
1711
|
+
timestamp,
|
|
1712
|
+
token_count,
|
|
1713
|
+
finish_reason,
|
|
1714
|
+
reasoning,
|
|
1715
|
+
reasoning_details,
|
|
1716
|
+
codex_reasoning_items
|
|
1717
|
+
FROM messages
|
|
1718
|
+
WHERE session_id = ?
|
|
1719
|
+
ORDER BY timestamp ASC, id ASC
|
|
1720
|
+
""",
|
|
1721
|
+
(session_id,),
|
|
1722
|
+
)
|
|
1723
|
+
|
|
1724
|
+
messages: list[dict[str, Any]] = []
|
|
1725
|
+
first_user_content: Optional[str] = None
|
|
1726
|
+
for row in cursor.fetchall():
|
|
1727
|
+
message = _hermes_row_to_message(row, truncate_large_content)
|
|
1728
|
+
if message is None:
|
|
1729
|
+
continue
|
|
1730
|
+
if (
|
|
1731
|
+
message["role"] == "user"
|
|
1732
|
+
and not first_user_content
|
|
1733
|
+
and not _is_poor_title_source(message["content"])
|
|
1734
|
+
):
|
|
1735
|
+
first_user_content = message["content"]
|
|
1736
|
+
messages.append(message)
|
|
1737
|
+
|
|
1738
|
+
if not messages:
|
|
1739
|
+
raise SessionImportError(
|
|
1740
|
+
"No valid user/assistant/tool messages found in Hermes session"
|
|
1741
|
+
)
|
|
1742
|
+
if not any(message["role"] == "user" for message in messages):
|
|
1743
|
+
raise SessionImportError("Hermes session needs at least one user message")
|
|
1744
|
+
|
|
1745
|
+
title = str(session_row["title"] or "").strip()
|
|
1746
|
+
if not title:
|
|
1747
|
+
title = _build_hermes_title(first_user_content, session_row)
|
|
1748
|
+
|
|
1749
|
+
hermes_source = session_row["source"]
|
|
1750
|
+
return {
|
|
1751
|
+
# Live Hermes provider uses the Hermes session id as the Mem thread
|
|
1752
|
+
# id. Keep it so history sync appends to already captured sessions.
|
|
1753
|
+
"thread_id": session_id,
|
|
1754
|
+
"title": title,
|
|
1755
|
+
"messages": messages,
|
|
1756
|
+
"source": "hermes",
|
|
1757
|
+
"project": str(hermes_source) if hermes_source else None,
|
|
1758
|
+
"metadata": {
|
|
1759
|
+
"session_id": session_id,
|
|
1760
|
+
"hermes_session_id": session_id,
|
|
1761
|
+
"hermes_source": hermes_source,
|
|
1762
|
+
"user_id": session_row["user_id"],
|
|
1763
|
+
"model": session_row["model"],
|
|
1764
|
+
"model_config": _parse_json_maybe(session_row["model_config"]),
|
|
1765
|
+
"parent_session_id": session_row["parent_session_id"],
|
|
1766
|
+
"started_at": session_row["started_at"],
|
|
1767
|
+
"ended_at": session_row["ended_at"],
|
|
1768
|
+
"end_reason": session_row["end_reason"],
|
|
1769
|
+
"message_count": session_row["message_count"],
|
|
1770
|
+
"tool_call_count": session_row["tool_call_count"],
|
|
1771
|
+
"input_tokens": session_row["input_tokens"],
|
|
1772
|
+
"output_tokens": session_row["output_tokens"],
|
|
1773
|
+
"cache_read_tokens": session_row["cache_read_tokens"],
|
|
1774
|
+
"cache_write_tokens": session_row["cache_write_tokens"],
|
|
1775
|
+
"reasoning_tokens": session_row["reasoning_tokens"],
|
|
1776
|
+
"billing_provider": session_row["billing_provider"],
|
|
1777
|
+
"billing_base_url": session_row["billing_base_url"],
|
|
1778
|
+
"billing_mode": session_row["billing_mode"],
|
|
1779
|
+
"estimated_cost_usd": session_row["estimated_cost_usd"],
|
|
1780
|
+
"actual_cost_usd": session_row["actual_cost_usd"],
|
|
1781
|
+
"cost_status": session_row["cost_status"],
|
|
1782
|
+
"cost_source": session_row["cost_source"],
|
|
1783
|
+
"pricing_version": session_row["pricing_version"],
|
|
1784
|
+
"storage": "sqlite",
|
|
1785
|
+
"original_file_path": str(db_path),
|
|
1786
|
+
},
|
|
1787
|
+
}
|
|
1788
|
+
finally:
|
|
1789
|
+
conn.close()
|
|
1790
|
+
|
|
1791
|
+
|
|
1792
|
+
def _hermes_row_to_message(
|
|
1793
|
+
row: sqlite3.Row,
|
|
1794
|
+
truncate_large_content: bool,
|
|
1795
|
+
) -> Optional[dict[str, Any]]:
|
|
1796
|
+
original_role = str(row["role"] or "").strip()
|
|
1797
|
+
if original_role == "session_meta":
|
|
1798
|
+
return None
|
|
1799
|
+
|
|
1800
|
+
role = "user" if original_role == "user" else "assistant"
|
|
1801
|
+
content = _hermes_message_content(row, truncate_large_content).strip()
|
|
1802
|
+
if not content:
|
|
1803
|
+
return None
|
|
1804
|
+
|
|
1805
|
+
message_id = str(row["id"])
|
|
1806
|
+
metadata = {
|
|
1807
|
+
"external_id": f"hermes-message-{message_id}",
|
|
1808
|
+
"hermes_message_id": message_id,
|
|
1809
|
+
"hermes_role": original_role,
|
|
1810
|
+
"tool_call_id": row["tool_call_id"],
|
|
1811
|
+
"tool_name": row["tool_name"],
|
|
1812
|
+
"token_count": row["token_count"],
|
|
1813
|
+
"finish_reason": row["finish_reason"],
|
|
1814
|
+
"source_app": "hermes",
|
|
1815
|
+
}
|
|
1816
|
+
return {
|
|
1817
|
+
"role": role,
|
|
1818
|
+
"content": content,
|
|
1819
|
+
"timestamp": row["timestamp"],
|
|
1820
|
+
"metadata": {
|
|
1821
|
+
key: value for key, value in metadata.items() if value is not None
|
|
1822
|
+
},
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
|
|
1826
|
+
def _hermes_message_content(row: sqlite3.Row, truncate_large_content: bool) -> str:
|
|
1827
|
+
role = str(row["role"] or "").strip()
|
|
1828
|
+
content = _string_or_empty(row["content"])
|
|
1829
|
+
|
|
1830
|
+
if role == "tool":
|
|
1831
|
+
return _format_hermes_tool_message(
|
|
1832
|
+
content,
|
|
1833
|
+
tool_name=_string_or_empty(row["tool_name"]),
|
|
1834
|
+
truncate_large_content=truncate_large_content,
|
|
1835
|
+
)
|
|
1836
|
+
|
|
1837
|
+
if content:
|
|
1838
|
+
return _truncate_if_requested(content, truncate_large_content)
|
|
1839
|
+
|
|
1840
|
+
tool_calls = _string_or_empty(row["tool_calls"])
|
|
1841
|
+
if tool_calls:
|
|
1842
|
+
return _format_hermes_json_block(
|
|
1843
|
+
"Hermes tool call",
|
|
1844
|
+
tool_calls,
|
|
1845
|
+
truncate_large_content=truncate_large_content,
|
|
1846
|
+
)
|
|
1847
|
+
|
|
1848
|
+
reasoning = _string_or_empty(row["reasoning"])
|
|
1849
|
+
if reasoning:
|
|
1850
|
+
content = f"<thinking>{reasoning}</thinking>"
|
|
1851
|
+
return _truncate_if_requested(content, truncate_large_content)
|
|
1852
|
+
|
|
1853
|
+
reasoning_details = _string_or_empty(row["reasoning_details"])
|
|
1854
|
+
if reasoning_details:
|
|
1855
|
+
return _format_hermes_json_block(
|
|
1856
|
+
"Hermes reasoning",
|
|
1857
|
+
reasoning_details,
|
|
1858
|
+
truncate_large_content=truncate_large_content,
|
|
1859
|
+
)
|
|
1860
|
+
|
|
1861
|
+
codex_reasoning_items = _string_or_empty(row["codex_reasoning_items"])
|
|
1862
|
+
if codex_reasoning_items:
|
|
1863
|
+
return _format_hermes_json_block(
|
|
1864
|
+
"Hermes reasoning",
|
|
1865
|
+
codex_reasoning_items,
|
|
1866
|
+
truncate_large_content=truncate_large_content,
|
|
1867
|
+
)
|
|
1868
|
+
|
|
1869
|
+
return ""
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
def _format_hermes_tool_message(
|
|
1873
|
+
content: str,
|
|
1874
|
+
*,
|
|
1875
|
+
tool_name: str,
|
|
1876
|
+
truncate_large_content: bool,
|
|
1877
|
+
) -> str:
|
|
1878
|
+
label = f"Hermes tool result: {tool_name}" if tool_name else "Hermes tool result"
|
|
1879
|
+
if not content:
|
|
1880
|
+
return f"[{label}]"
|
|
1881
|
+
|
|
1882
|
+
parsed = _parse_json_maybe(content)
|
|
1883
|
+
if isinstance(parsed, dict):
|
|
1884
|
+
for key in ("output", "result", "content", "text", "error"):
|
|
1885
|
+
value = parsed.get(key)
|
|
1886
|
+
if isinstance(value, str) and value.strip():
|
|
1887
|
+
value = _truncate_if_requested(value.strip(), truncate_large_content)
|
|
1888
|
+
return f"[{label}]\n{value}"
|
|
1889
|
+
content = json.dumps(parsed, ensure_ascii=False)
|
|
1890
|
+
|
|
1891
|
+
return f"[{label}]\n{_truncate_if_requested(content, truncate_large_content)}"
|
|
1892
|
+
|
|
1893
|
+
|
|
1894
|
+
def _format_hermes_json_block(
|
|
1895
|
+
label: str,
|
|
1896
|
+
value: str,
|
|
1897
|
+
*,
|
|
1898
|
+
truncate_large_content: bool,
|
|
1899
|
+
) -> str:
|
|
1900
|
+
parsed = _parse_json_maybe(value)
|
|
1901
|
+
if parsed is not None:
|
|
1902
|
+
try:
|
|
1903
|
+
value = json.dumps(parsed, ensure_ascii=False)
|
|
1904
|
+
except TypeError:
|
|
1905
|
+
pass
|
|
1906
|
+
return f"[{label}]\n{_truncate_if_requested(value, truncate_large_content)}"
|
|
1907
|
+
|
|
1908
|
+
|
|
1909
|
+
def _build_hermes_title(
|
|
1910
|
+
first_user_content: Optional[str],
|
|
1911
|
+
session_row: sqlite3.Row,
|
|
1912
|
+
) -> str:
|
|
1913
|
+
if first_user_content:
|
|
1914
|
+
title = first_user_content[:80].replace("\n", " ").strip()
|
|
1915
|
+
if len(first_user_content) > 80:
|
|
1916
|
+
title += "..."
|
|
1917
|
+
if title:
|
|
1918
|
+
return title
|
|
1919
|
+
|
|
1920
|
+
source = str(session_row["source"] or "").strip()
|
|
1921
|
+
if source:
|
|
1922
|
+
return f"Hermes {source} session"
|
|
1923
|
+
return "Hermes session"
|
|
1924
|
+
|
|
1925
|
+
|
|
1926
|
+
def _parse_json_maybe(value: Any) -> Any:
|
|
1927
|
+
if not isinstance(value, str) or not value.strip():
|
|
1928
|
+
return None
|
|
1929
|
+
try:
|
|
1930
|
+
return json.loads(value)
|
|
1931
|
+
except Exception:
|
|
1932
|
+
return None
|
|
1933
|
+
|
|
1934
|
+
|
|
1935
|
+
def _string_or_empty(value: Any) -> str:
|
|
1936
|
+
return value.strip() if isinstance(value, str) else ""
|
|
1937
|
+
|
|
1938
|
+
|
|
1939
|
+
def _truncate_if_requested(value: str, truncate_large_content: bool) -> str:
|
|
1940
|
+
if not truncate_large_content or len(value) <= 10_000:
|
|
1941
|
+
return value
|
|
1942
|
+
return value[:10_000] + "\n\n[truncated by nmem t sync --truncate]"
|
|
1943
|
+
|
|
1944
|
+
|
|
1274
1945
|
def parse_opencode_session(
|
|
1275
1946
|
file_path: Path,
|
|
1276
1947
|
*,
|
|
@@ -66,6 +66,9 @@ PROVIDER_DEFAULTS: dict[str, dict[str, str]] = {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
ACCESS_ANYWHERE_DOCS_URL = "https://mem.nowledge.co/docs/remote-access"
|
|
69
|
+
DEFAULT_MAX_TOKENS_PER_HOUR = 5_000_000
|
|
70
|
+
DEFAULT_MAX_TOKENS_PER_DAY = 10_000_000
|
|
71
|
+
DEFAULT_MAX_TOKENS_PER_TASK = 2_000_000
|
|
69
72
|
|
|
70
73
|
|
|
71
74
|
def _normalize_account_tunnel_url_input(value: str) -> str:
|
|
@@ -1919,9 +1922,9 @@ class SettingsScreen(VerticalScroll):
|
|
|
1919
1922
|
# Get current limits from settings
|
|
1920
1923
|
kp_data = await self.api_client.get_knowledge_processing_settings()
|
|
1921
1924
|
settings = kp_data.get("settings", {})
|
|
1922
|
-
max_hour = settings.get("maxTokensPerHour",
|
|
1923
|
-
max_day = settings.get("maxTokensPerDay",
|
|
1924
|
-
max_task = settings.get("maxTokensPerTask",
|
|
1925
|
+
max_hour = settings.get("maxTokensPerHour", DEFAULT_MAX_TOKENS_PER_HOUR)
|
|
1926
|
+
max_day = settings.get("maxTokensPerDay", DEFAULT_MAX_TOKENS_PER_DAY)
|
|
1927
|
+
max_task = settings.get("maxTokensPerTask", DEFAULT_MAX_TOKENS_PER_TASK)
|
|
1925
1928
|
|
|
1926
1929
|
# Get current usage from agent status
|
|
1927
1930
|
agent_data = await self.api_client.get_agent_status()
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|