lorekeep 0.4.0__tar.gz → 0.5.0__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 (132) hide show
  1. lorekeep-0.5.0/.release-please-manifest.json +3 -0
  2. {lorekeep-0.4.0 → lorekeep-0.5.0}/CHANGELOG.md +7 -0
  3. {lorekeep-0.4.0 → lorekeep-0.5.0}/PKG-INFO +1 -1
  4. {lorekeep-0.4.0 → lorekeep-0.5.0}/pyproject.toml +1 -1
  5. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/__init__.py +1 -1
  6. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/cli.py +88 -35
  7. lorekeep-0.5.0/src/lorekeep/importer/codex.py +287 -0
  8. lorekeep-0.5.0/src/lorekeep/importer/opencode.py +255 -0
  9. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/integrations/codex.py +32 -6
  10. lorekeep-0.5.0/src/lorekeep/integrations/opencode.py +60 -0
  11. lorekeep-0.5.0/tests/test_import_codex.py +238 -0
  12. lorekeep-0.5.0/tests/test_import_opencode.py +170 -0
  13. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_session_hook.py +75 -22
  14. lorekeep-0.4.0/.release-please-manifest.json +0 -3
  15. lorekeep-0.4.0/src/lorekeep/integrations/opencode.py +0 -43
  16. {lorekeep-0.4.0 → lorekeep-0.5.0}/.commitlintrc.json +0 -0
  17. {lorekeep-0.4.0 → lorekeep-0.5.0}/.github/workflows/auto-merge-release-please.yml +0 -0
  18. {lorekeep-0.4.0 → lorekeep-0.5.0}/.github/workflows/ci.yml +0 -0
  19. {lorekeep-0.4.0 → lorekeep-0.5.0}/.github/workflows/lint-commits.yml +0 -0
  20. {lorekeep-0.4.0 → lorekeep-0.5.0}/.github/workflows/release-please.yml +0 -0
  21. {lorekeep-0.4.0 → lorekeep-0.5.0}/.gitignore +0 -0
  22. {lorekeep-0.4.0 → lorekeep-0.5.0}/.lorekeep/config.yaml.example +0 -0
  23. {lorekeep-0.4.0 → lorekeep-0.5.0}/.lorekeep/schema.json +0 -0
  24. {lorekeep-0.4.0 → lorekeep-0.5.0}/.pre-commit-config.yaml +0 -0
  25. {lorekeep-0.4.0 → lorekeep-0.5.0}/AGENTS.md +0 -0
  26. {lorekeep-0.4.0 → lorekeep-0.5.0}/CLAUDE.md +0 -0
  27. {lorekeep-0.4.0 → lorekeep-0.5.0}/LICENSE +0 -0
  28. {lorekeep-0.4.0 → lorekeep-0.5.0}/README.md +0 -0
  29. {lorekeep-0.4.0 → lorekeep-0.5.0}/SECURITY.md +0 -0
  30. {lorekeep-0.4.0 → lorekeep-0.5.0}/cover.jpeg +0 -0
  31. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/README.md +0 -0
  32. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/architecture/agent.md +0 -0
  33. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/architecture/data-model.md +0 -0
  34. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/architecture/evaluation.md +0 -0
  35. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/architecture/journal.md +0 -0
  36. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/architecture/overview.md +0 -0
  37. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/architecture/permission.md +0 -0
  38. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/architecture/pipeline.md +0 -0
  39. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/architecture/serve-mcp.md +0 -0
  40. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/architecture/temporal.md +0 -0
  41. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/guides/backup.md +0 -0
  42. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/guides/compile.md +0 -0
  43. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/guides/data-home.md +0 -0
  44. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/guides/getting-started.md +0 -0
  45. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/guides/import.md +0 -0
  46. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/guides/serve.md +0 -0
  47. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/superpowers/plans/2026-06-26-lorekeep-dotdir-backup.md +0 -0
  48. {lorekeep-0.4.0 → lorekeep-0.5.0}/docs/superpowers/specs/2026-06-26-lorekeep-dotdir-layout-design.md +0 -0
  49. {lorekeep-0.4.0 → lorekeep-0.5.0}/release-please-config.json +0 -0
  50. {lorekeep-0.4.0 → lorekeep-0.5.0}/scripts/check-conventional-commit.py +0 -0
  51. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/agent.py +0 -0
  52. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/backup.py +0 -0
  53. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/compile/__init__.py +0 -0
  54. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/compile/extract.py +0 -0
  55. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/compile/ingest.py +0 -0
  56. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/compile/providers.py +0 -0
  57. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/compile/resolve.py +0 -0
  58. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/compile/writer.py +0 -0
  59. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/config.py +0 -0
  60. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/defaults.py +0 -0
  61. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/eval/__init__.py +0 -0
  62. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/eval/construction.py +0 -0
  63. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/eval/gold.py +0 -0
  64. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/eval/retrieval.py +0 -0
  65. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/facts_io.py +0 -0
  66. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/importer/__init__.py +0 -0
  67. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/importer/claude.py +0 -0
  68. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/importer/cursor.py +0 -0
  69. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/integrations/__init__.py +0 -0
  70. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/integrations/claude_code.py +0 -0
  71. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/integrations/common.py +0 -0
  72. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/integrations/cursor.py +0 -0
  73. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/integrations/detect.py +0 -0
  74. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/journal.py +0 -0
  75. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/mcp_server.py +0 -0
  76. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/models.py +0 -0
  77. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/paths.py +0 -0
  78. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/perm/__init__.py +0 -0
  79. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/perm/ns.py +0 -0
  80. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/pipeline.py +0 -0
  81. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/schema_io.py +0 -0
  82. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/store/__init__.py +0 -0
  83. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/store/fts.py +0 -0
  84. {lorekeep-0.4.0 → lorekeep-0.5.0}/src/lorekeep/store/graph.py +0 -0
  85. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/conftest.py +0 -0
  86. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/fixtures/claude-session/memory/MEMORY.md +0 -0
  87. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/fixtures/claude-session/memory/lorekeep-design.md +0 -0
  88. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/fixtures/claude-session/memory/lorekeep-project-state.md +0 -0
  89. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/fixtures/claude-session/test.jsonl +0 -0
  90. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/fixtures/gold/payments.facts.jsonl +0 -0
  91. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/fixtures/raw/backend/payments.md +0 -0
  92. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/fixtures/retrieval/questions.json +0 -0
  93. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/fixtures/schema.json +0 -0
  94. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_agent_cli.py +0 -0
  95. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_agent_detect.py +0 -0
  96. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_backup.py +0 -0
  97. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_backup_cli.py +0 -0
  98. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_compile_cli.py +0 -0
  99. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_config.py +0 -0
  100. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_construction_eval.py +0 -0
  101. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_defaults.py +0 -0
  102. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_determinism.py +0 -0
  103. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_doctor_cli.py +0 -0
  104. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_eval_cli.py +0 -0
  105. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_extract.py +0 -0
  106. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_fts.py +0 -0
  107. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_graph_store.py +0 -0
  108. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_import_claude.py +0 -0
  109. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_import_cursor.py +0 -0
  110. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_ingest.py +0 -0
  111. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_init_chain.py +0 -0
  112. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_init_cli.py +0 -0
  113. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_integrations.py +0 -0
  114. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_mcp_add_cli.py +0 -0
  115. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_mcp_reload.py +0 -0
  116. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_mcp_server.py +0 -0
  117. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_mcp_write_tools.py +0 -0
  118. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_merge_journals.py +0 -0
  119. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_models.py +0 -0
  120. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_paths.py +0 -0
  121. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_perm.py +0 -0
  122. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_pipeline.py +0 -0
  123. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_providers.py +0 -0
  124. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_resolve.py +0 -0
  125. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_retrieval_eval.py +0 -0
  126. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_schema_io.py +0 -0
  127. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_serve_cli.py +0 -0
  128. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_smoke.py +0 -0
  129. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_watch_e2e.py +0 -0
  130. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_watch_sessions.py +0 -0
  131. {lorekeep-0.4.0 → lorekeep-0.5.0}/tests/test_writer.py +0 -0
  132. {lorekeep-0.4.0 → lorekeep-0.5.0}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.5.0"
3
+ }
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0](https://github.com/manhhailua/lorekeep/compare/v0.4.0...v0.5.0) (2026-06-28)
4
+
5
+
6
+ ### Features
7
+
8
+ * add import --from codex and --from opencode + hooks for all 4 agents ([#66](https://github.com/manhhailua/lorekeep/issues/66)) ([8a1e364](https://github.com/manhhailua/lorekeep/commit/8a1e364e19cf1b45944cea2ed89d9d7468fec9c6))
9
+
3
10
  ## [0.4.0](https://github.com/manhhailua/lorekeep/compare/v0.3.1...v0.4.0) (2026-06-28)
4
11
 
5
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lorekeep
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Temporal knowledge graph for AI agents via MCP
5
5
  Project-URL: Homepage, https://github.com/manhhailua/lorekeep
6
6
  Project-URL: Repository, https://github.com/manhhailua/lorekeep
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lorekeep"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Temporal knowledge graph for AI agents via MCP"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -1,3 +1,3 @@
1
1
  """Lorekeep — temporal knowledge graph for AI agents via MCP."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
@@ -65,44 +65,35 @@ def version() -> None:
65
65
 
66
66
  @app.command(hidden=True)
67
67
  def hook() -> None:
68
- """SessionEnd hook: quick-import Claude memory files to raw/.
68
+ """Session lifecycle hook: quick-import memories from all agents.
69
69
 
70
- Designed to be registered as a Claude Code SessionEnd hook.
71
- Reads hook JSON from stdin (transcript_path), imports memory files,
72
- and lets the daemon pick up the raw/ change for compile.
70
+ Agent-agnostic — tries Claude and Codex memory imports (idempotent
71
+ via manifest). Each is a no-op if nothing changed. Daemon picks up
72
+ raw/ changes for compile.
73
73
  """
74
- import sys
74
+ p = resolve_paths()
75
+ total = 0
75
76
 
76
- session_dir = None
77
77
  try:
78
- raw_stdin = sys.stdin.read()
79
- if raw_stdin.strip():
80
- data = json.loads(raw_stdin)
81
- transcript_path = data.get("transcript_path", "")
82
- if transcript_path:
83
- session_dir = Path(transcript_path).expanduser().parent
78
+ from lorekeep.importer.claude import find_current_session as find_claude
79
+ from lorekeep.importer.claude import import_memories as import_claude_mem
80
+ session_dir = find_claude()
81
+ if session_dir and (session_dir / "memory").is_dir():
82
+ written = import_claude_mem(session_dir, p["raw"], "claude-memory")
83
+ total += len(written)
84
84
  except Exception:
85
85
  pass
86
86
 
87
- if session_dir is None:
88
- try:
89
- from lorekeep.importer.claude import find_current_session
90
- session_dir = find_current_session()
91
- except Exception:
92
- return
93
-
94
- if session_dir is None or not (session_dir / "memory").is_dir():
95
- return
96
-
97
- p = resolve_paths()
98
87
  try:
99
- from lorekeep.importer.claude import import_memories
100
- written = import_memories(session_dir, p["raw"], "claude-memory")
101
- if written:
102
- typer.echo(f"lorekeep: imported {len(written)} memory file(s)")
88
+ from lorekeep.importer.codex import import_memories as import_codex_mem
89
+ written = import_codex_mem(p["raw"], "codex-memory")
90
+ total += len(written)
103
91
  except Exception:
104
92
  pass
105
93
 
94
+ if total:
95
+ typer.echo(f"lorekeep: imported {total} memory file(s)")
96
+
106
97
 
107
98
  @app.command()
108
99
  def compile() -> None:
@@ -289,7 +280,7 @@ def mcp_add(
289
280
  raise typer.Exit(code=1)
290
281
  written = writers[agent].write_config(target, command, args, ns)
291
282
  typer.echo(f"wrote {agent} config -> {written}")
292
- if agent in ("claude", "cursor") and hasattr(writers[agent], "write_hook"):
283
+ if hasattr(writers[agent], "write_hook"):
293
284
  hook_path = writers[agent].write_hook(target, hook_cmd, hook_args)
294
285
  typer.echo(f"wrote session-end hook -> {hook_path}")
295
286
  typer.echo("\n" + agent_memory_snippet())
@@ -510,7 +501,7 @@ def _auto_wire_agents(p: dict, ns: str) -> None:
510
501
  try:
511
502
  written = writer.write_config(target, command, args, ns)
512
503
  typer.echo(f" wired {agent_name} -> {written}")
513
- if agent_name in ("claude", "cursor") and hasattr(writer, "write_hook"):
504
+ if hasattr(writer, "write_hook"):
514
505
  hook_path = writer.write_hook(target, hook_cmd, hook_args)
515
506
  typer.echo(f" hooked {agent_name} session-end -> {hook_path}")
516
507
  except Exception as exc:
@@ -663,18 +654,80 @@ def import_cmd(
663
654
  """Import knowledge from an agent's sessions into raw/.
664
655
 
665
656
  Sources:
666
- claude Claude Code sessions. --quick copies memory/*.md only (no LLM);
667
- default (deep) adds LLM-summarized transcript analysis.
668
- cursor Cursor composer conversations (GLOBAL state.vscdb). Deep-only --
669
- --quick is rejected (Cursor has no curated memory files).
657
+ claude Claude Code sessions. --quick copies memory/*.md only (no LLM);
658
+ default (deep) adds LLM-summarized transcript analysis.
659
+ cursor Cursor composer conversations (GLOBAL state.vscdb). Deep-only.
660
+ codex Codex CLI rollout transcripts ($CODEX_HOME/sessions/).
661
+ --quick copies memories/*.md only; default (deep) summarizes.
662
+ opencode opencode sessions (SQLite DB). Deep-only — no memory dir.
670
663
  """
671
- if from_source not in ("claude", "cursor"):
672
- typer.echo(f"unknown source: {from_source} (claude | cursor)")
664
+ if from_source not in ("claude", "cursor", "codex", "opencode"):
665
+ typer.echo(f"unknown source: {from_source} (claude | cursor | codex | opencode)")
673
666
  raise typer.Exit(code=1)
674
667
 
675
668
  p = resolve_paths()
676
669
  config = load_config(p["config"])
677
670
 
671
+ # --- Codex: rollout JSONL transcripts, quick + deep --------------------
672
+ if from_source == "codex":
673
+ from lorekeep.importer.codex import find_current_session as find_codex, import_codex
674
+
675
+ rollout_path = Path(session_path).expanduser() if session_path else None
676
+ if rollout_path is None:
677
+ rollout_path = find_codex()
678
+ if rollout_path is None and not quick:
679
+ typer.echo("error: no Codex session found for this project. "
680
+ "Run Codex CLI here first, or pass --session-path.")
681
+ raise typer.Exit(code=1)
682
+
683
+ result = import_codex(
684
+ raw_root=p["raw"],
685
+ rollout_path=rollout_path,
686
+ quick=quick,
687
+ memory_ns=memory_ns,
688
+ session_ns=session_ns or "codex-session",
689
+ provider=None if quick else _make_import_provider(config),
690
+ dry_run=dry_run,
691
+ )
692
+ mem_count = len(result.get("memory", []))
693
+ ses_count = len(result.get("session", []))
694
+ if dry_run:
695
+ typer.echo(f"dry-run: would import {mem_count} memories, {ses_count} session files")
696
+ else:
697
+ typer.echo(f"imported: {mem_count} memories -> raw/{memory_ns}/, "
698
+ f"{ses_count} session files -> raw/{session_ns or 'codex-session'}/")
699
+ return
700
+
701
+ # --- opencode: SQLite DB, deep-only ------------------------------------
702
+ if from_source == "opencode":
703
+ if quick:
704
+ typer.echo("error: opencode import is deep-only (--quick not supported)")
705
+ raise typer.Exit(code=1)
706
+
707
+ from lorekeep.importer.opencode import find_current_session as find_oc, import_opencode
708
+
709
+ sid = session_path or find_oc()
710
+ if sid is None:
711
+ typer.echo("error: no opencode session found for this project. "
712
+ "Run opencode here first, or pass --session-path <session-id>.")
713
+ raise typer.Exit(code=1)
714
+
715
+ ns = session_ns or "opencode-session"
716
+ result = import_opencode(
717
+ raw_root=p["raw"],
718
+ session_id=sid,
719
+ session_ns=ns,
720
+ provider=_make_import_provider(config),
721
+ dry_run=dry_run,
722
+ )
723
+ ses_count = len(result.get("session", []))
724
+ if dry_run:
725
+ typer.echo(f"dry-run: would import {ses_count} opencode session files")
726
+ else:
727
+ typer.echo(f"imported: {ses_count} session files -> raw/{ns}/")
728
+ typer.echo("next: lorekeep compile")
729
+ return
730
+
678
731
  # --- Cursor: global composer conversations, deep-only ------------------
679
732
  if from_source == "cursor":
680
733
  if quick:
@@ -0,0 +1,287 @@
1
+ """Import knowledge from Codex CLI sessions into lorekeep raw/ tree.
2
+
3
+ Codex stores sessions as JSONL rollout files at:
4
+ $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl
5
+
6
+ Each line = {"timestamp": "...", "type": "...", "payload": {...}}
7
+ The first line is always "session_meta" with cwd, session_id, model.
8
+ Conversation turns are in "response_item" lines with payload.type == "message".
9
+
10
+ Memory files live at $CODEX_HOME/memories/*.md (optional, may not exist).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ from pathlib import Path
19
+
20
+ from lorekeep.compile.providers import LLMProvider
21
+ from lorekeep.importer.claude import (
22
+ ConversationTurn,
23
+ chunk_turns,
24
+ summarize_batch,
25
+ load_import_manifest,
26
+ save_import_manifest,
27
+ )
28
+
29
+ _CODEX_PREFIX_RE = re.compile(r"^## My request for Codex:\n", re.DOTALL)
30
+
31
+
32
+ def _codex_home() -> Path:
33
+ return Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Session discovery
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ def find_current_session(cwd: Path | None = None) -> Path | None:
42
+ """Find the most recent Codex rollout file for the given cwd.
43
+
44
+ Codex shards sessions by date, not by project. The cwd is stored
45
+ inside the first line (session_meta) of each rollout file.
46
+ """
47
+ cwd = str((cwd or Path.cwd()).resolve())
48
+ sessions_dir = _codex_home() / "sessions"
49
+ if not sessions_dir.is_dir():
50
+ return None
51
+
52
+ rollouts = sorted(sessions_dir.rglob("rollout-*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
53
+ for rp in rollouts:
54
+ try:
55
+ first_line = rp.read_text(encoding="utf-8").splitlines()[0].strip()
56
+ if not first_line:
57
+ continue
58
+ rec = json.loads(first_line)
59
+ if rec.get("type") == "session_meta":
60
+ session_cwd = rec.get("payload", {}).get("cwd", "")
61
+ if session_cwd and Path(session_cwd).resolve() == Path(cwd):
62
+ return rp
63
+ except (json.JSONDecodeError, IndexError, OSError):
64
+ continue
65
+ return None
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Transcript parsing
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ def _extract_message_text(content: list[dict] | str) -> str:
74
+ """Extract text from a Codex message content array."""
75
+ if isinstance(content, str):
76
+ return content
77
+ parts = []
78
+ for block in content:
79
+ if isinstance(block, dict):
80
+ text = block.get("text", "")
81
+ if text:
82
+ parts.append(text)
83
+ return "\n".join(parts)
84
+
85
+
86
+ def _strip_codex_prefix(text: str) -> str:
87
+ """Strip Codex's '## My request for Codex:' prefix from user messages."""
88
+ return _CODEX_PREFIX_RE.sub("", text)
89
+
90
+
91
+ def parse_rollout(rollout_path: Path) -> list[ConversationTurn]:
92
+ """Parse a Codex rollout JSONL file into structured conversation turns."""
93
+ turns: list[ConversationTurn] = []
94
+ current_user: str | None = None
95
+ current_assistant: list[str] = []
96
+ current_tools: list[str] = []
97
+
98
+ for line in rollout_path.read_text(encoding="utf-8").splitlines():
99
+ line = line.strip()
100
+ if not line:
101
+ continue
102
+ try:
103
+ rec = json.loads(line)
104
+ except json.JSONDecodeError:
105
+ continue
106
+
107
+ if rec.get("type") != "response_item":
108
+ continue
109
+
110
+ payload = rec.get("payload", {})
111
+ ptype = payload.get("type", "")
112
+
113
+ if ptype == "message":
114
+ role = payload.get("role", "")
115
+ text = _extract_message_text(payload.get("content", []))
116
+
117
+ if role == "user":
118
+ if current_user is not None:
119
+ turns.append(ConversationTurn(
120
+ user_content=current_user,
121
+ assistant_text="\n\n".join(current_assistant),
122
+ tool_calls=list(current_tools),
123
+ ))
124
+ current_user = _strip_codex_prefix(text)
125
+ current_assistant = []
126
+ current_tools = []
127
+ elif role == "assistant":
128
+ current_assistant.append(text)
129
+
130
+ elif ptype in ("function_call", "custom_tool_call"):
131
+ name = payload.get("name", "unknown")
132
+ if name not in current_tools:
133
+ current_tools.append(name)
134
+
135
+ if current_user is not None:
136
+ turns.append(ConversationTurn(
137
+ user_content=current_user,
138
+ assistant_text="\n\n".join(current_assistant),
139
+ tool_calls=list(current_tools),
140
+ ))
141
+
142
+ return turns
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Memory import (quick mode)
147
+ # ---------------------------------------------------------------------------
148
+
149
+
150
+ def import_memories(
151
+ raw_root: Path,
152
+ namespace: str = "codex-memory",
153
+ *,
154
+ dry_run: bool = False,
155
+ ) -> list[Path]:
156
+ """Copy memories/*.md files from $CODEX_HOME into raw/<namespace>/.
157
+
158
+ Returns list of written paths. Idempotent via SHA-256 manifest.
159
+ """
160
+ mem_dir = _codex_home() / "memories"
161
+ if not mem_dir.is_dir():
162
+ return []
163
+
164
+ dest_dir = raw_root / namespace
165
+ manifest = load_import_manifest(raw_root, namespace)
166
+ written: list[Path] = []
167
+
168
+ for src in sorted(mem_dir.glob("*.md")):
169
+ content = src.read_text(encoding="utf-8")
170
+ h = hashlib.sha256(content.encode()).hexdigest()
171
+ if manifest.get(str(src)) == h:
172
+ continue
173
+
174
+ if dry_run:
175
+ written.append(dest_dir / src.name)
176
+ continue
177
+
178
+ dest_dir.mkdir(parents=True, exist_ok=True)
179
+ dest = dest_dir / src.name
180
+ dest.write_text(content, encoding="utf-8")
181
+ manifest[str(src)] = h
182
+ written.append(dest)
183
+
184
+ if not dry_run and written:
185
+ save_import_manifest(raw_root, namespace, manifest)
186
+
187
+ return written
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Deep session import
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ def import_session_deep(
196
+ rollout_path: Path,
197
+ raw_root: Path,
198
+ namespace: str = "codex-session",
199
+ provider: LLMProvider | None = None,
200
+ *,
201
+ batch_max_chars: int = 20_000,
202
+ dry_run: bool = False,
203
+ ) -> list[Path]:
204
+ """Deep-import a Codex session: parse, chunk, summarize each batch via LLM."""
205
+ session_id = rollout_path.stem
206
+
207
+ transcript_hash = hashlib.sha256(rollout_path.read_bytes()).hexdigest()
208
+ manifest = load_import_manifest(raw_root, namespace)
209
+ manifest_key = str(rollout_path)
210
+
211
+ if not dry_run and manifest.get(manifest_key) == transcript_hash:
212
+ return []
213
+
214
+ turns = parse_rollout(rollout_path)
215
+ if not turns:
216
+ return []
217
+
218
+ batches = chunk_turns(turns, max_chars=batch_max_chars)
219
+ dest_dir = raw_root / namespace
220
+ written: list[Path] = []
221
+
222
+ previous_summary = ""
223
+ for i, batch in enumerate(batches):
224
+ if dry_run:
225
+ dest = dest_dir / f"session-{session_id}-batch-{i + 1:02d}.md"
226
+ written.append(dest)
227
+ continue
228
+
229
+ dest_dir.mkdir(parents=True, exist_ok=True)
230
+ dest = dest_dir / f"session-{session_id}-batch-{i + 1:02d}.md"
231
+
232
+ try:
233
+ if provider is None:
234
+ raise RuntimeError("deep mode requires a provider")
235
+ md = summarize_batch(
236
+ batch, i, len(batches), namespace, session_id,
237
+ provider, previous_summary,
238
+ )
239
+ except Exception as exc:
240
+ md = f"# Error summarizing batch {i + 1}\n\nLLM call failed: {exc}\n"
241
+
242
+ dest.write_text(md, encoding="utf-8")
243
+ previous_summary = md[:1000]
244
+ written.append(dest)
245
+
246
+ if not dry_run and written:
247
+ manifest[manifest_key] = transcript_hash
248
+ save_import_manifest(raw_root, namespace, manifest)
249
+
250
+ return written
251
+
252
+
253
+ # ---------------------------------------------------------------------------
254
+ # Orchestrator
255
+ # ---------------------------------------------------------------------------
256
+
257
+
258
+ def import_codex(
259
+ raw_root: Path,
260
+ rollout_path: Path | None = None,
261
+ *,
262
+ quick: bool = False,
263
+ memory_ns: str = "codex-memory",
264
+ session_ns: str = "codex-session",
265
+ provider: LLMProvider | None = None,
266
+ batch_max_chars: int = 20_000,
267
+ dry_run: bool = False,
268
+ ) -> dict[str, list[Path]]:
269
+ """Import a Codex session into the lorekeep raw/ tree.
270
+
271
+ Returns ``{"memory": [...], "session": [...]}`` with written file paths.
272
+ """
273
+ result: dict[str, list[Path]] = {"memory": [], "session": []}
274
+
275
+ result["memory"] = import_memories(raw_root, namespace=memory_ns, dry_run=dry_run)
276
+
277
+ if not quick:
278
+ if rollout_path is None:
279
+ rollout_path = find_current_session()
280
+ if rollout_path is not None:
281
+ result["session"] = import_session_deep(
282
+ rollout_path, raw_root, namespace=session_ns,
283
+ provider=provider, batch_max_chars=batch_max_chars,
284
+ dry_run=dry_run,
285
+ )
286
+
287
+ return result