nmem-cli 0.9.4__tar.gz → 0.9.8__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.4 → nmem_cli-0.9.8}/PKG-INFO +25 -8
  2. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/README.md +24 -7
  3. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/pyproject.toml +1 -1
  4. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/__init__.py +1 -1
  5. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/cli.py +23 -11
  6. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/session_import.py +674 -3
  7. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/tui/screens/settings.py +6 -3
  8. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/.gitignore +0 -0
  9. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/agent_profiles.py +0 -0
  10. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/claude_paths.py +0 -0
  11. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/data_transfer_paths.py +0 -0
  12. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/guidance_rules.py +0 -0
  13. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/kfs_cli.py +0 -0
  14. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/license_payload.py +0 -0
  15. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/memories_reclassify.py +0 -0
  16. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/memory_relations_cli.py +0 -0
  17. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/py.typed +0 -0
  18. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/tui/__init__.py +0 -0
  19. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/tui/__main__.py +0 -0
  20. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/tui/api_client.py +0 -0
  21. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/tui/app.py +0 -0
  22. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/tui/screens/__init__.py +0 -0
  23. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/tui/screens/dashboard.py +0 -0
  24. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/tui/screens/graph.py +0 -0
  25. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/tui/screens/help.py +0 -0
  26. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/tui/screens/memories.py +0 -0
  27. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/tui/screens/memory_detail.py +0 -0
  28. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/tui/screens/thread_detail.py +0 -0
  29. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/src/nmem_cli/tui/screens/threads.py +0 -0
  30. {nmem_cli-0.9.4 → nmem_cli-0.9.8}/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.4
3
+ Version: 0.9.8
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, 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.
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 ...` works correctly with remote Mem too: the server does not need direct access to those local agent directories on your laptop.
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 `pi`) - required
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 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.
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 files on the same machine as the Mem server
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, 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.
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 ...` works correctly with remote Mem too: the server does not need direct access to those local agent directories on your laptop.
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 `pi`) - required
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 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.
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 files on the same machine as the Mem server
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nmem-cli"
3
- version = "0.9.4"
3
+ version = "0.9.8"
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.4"
23
+ __version__ = "0.9.8"
24
24
  __author__ = "Nowledge Labs"
25
25
 
26
26
  from .cli import main
@@ -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 can safely preview all projects by default. Other clients historically
5374
- # key discovery by project path, so keep their default scoped to cwd.
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 == "pi" else "."
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", 500_000)
9222
- max_day = settings.get("maxTokensPerDay", 2_000_000)
9223
- max_task = settings.get("maxTokensPerTask", 500_000)
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 pi --session-dir ~/.pi/agent/sessions --limit 20
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 Pi, "
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="Pi session directory or JSONL file to scan. May be repeated.",
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 Pi change their transcript
10
- formats or storage layout, update this client module and the corresponding
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 = ("claude-code", "codex", "gemini-cli", "opencode", "pi")
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", 500_000)
1923
- max_day = settings.get("maxTokensPerDay", 2_000_000)
1924
- max_task = settings.get("maxTokensPerTask", 500_000)
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