lorekeep 0.3.1__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 (134) hide show
  1. lorekeep-0.5.0/.release-please-manifest.json +3 -0
  2. {lorekeep-0.3.1 → lorekeep-0.5.0}/CHANGELOG.md +14 -0
  3. {lorekeep-0.3.1 → lorekeep-0.5.0}/PKG-INFO +1 -1
  4. {lorekeep-0.3.1 → lorekeep-0.5.0}/pyproject.toml +1 -1
  5. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/__init__.py +1 -1
  6. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/cli.py +108 -6
  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.5.0/src/lorekeep/integrations/claude_code.py +50 -0
  10. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/integrations/codex.py +32 -6
  11. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/integrations/common.py +10 -7
  12. lorekeep-0.5.0/src/lorekeep/integrations/cursor.py +45 -0
  13. lorekeep-0.5.0/src/lorekeep/integrations/opencode.py +60 -0
  14. lorekeep-0.5.0/tests/test_import_codex.py +238 -0
  15. lorekeep-0.5.0/tests/test_import_opencode.py +170 -0
  16. lorekeep-0.5.0/tests/test_session_hook.py +299 -0
  17. lorekeep-0.3.1/.release-please-manifest.json +0 -3
  18. lorekeep-0.3.1/src/lorekeep/integrations/claude_code.py +0 -19
  19. lorekeep-0.3.1/src/lorekeep/integrations/cursor.py +0 -21
  20. lorekeep-0.3.1/src/lorekeep/integrations/opencode.py +0 -43
  21. {lorekeep-0.3.1 → lorekeep-0.5.0}/.commitlintrc.json +0 -0
  22. {lorekeep-0.3.1 → lorekeep-0.5.0}/.github/workflows/auto-merge-release-please.yml +0 -0
  23. {lorekeep-0.3.1 → lorekeep-0.5.0}/.github/workflows/ci.yml +0 -0
  24. {lorekeep-0.3.1 → lorekeep-0.5.0}/.github/workflows/lint-commits.yml +0 -0
  25. {lorekeep-0.3.1 → lorekeep-0.5.0}/.github/workflows/release-please.yml +0 -0
  26. {lorekeep-0.3.1 → lorekeep-0.5.0}/.gitignore +0 -0
  27. {lorekeep-0.3.1 → lorekeep-0.5.0}/.lorekeep/config.yaml.example +0 -0
  28. {lorekeep-0.3.1 → lorekeep-0.5.0}/.lorekeep/schema.json +0 -0
  29. {lorekeep-0.3.1 → lorekeep-0.5.0}/.pre-commit-config.yaml +0 -0
  30. {lorekeep-0.3.1 → lorekeep-0.5.0}/AGENTS.md +0 -0
  31. {lorekeep-0.3.1 → lorekeep-0.5.0}/CLAUDE.md +0 -0
  32. {lorekeep-0.3.1 → lorekeep-0.5.0}/LICENSE +0 -0
  33. {lorekeep-0.3.1 → lorekeep-0.5.0}/README.md +0 -0
  34. {lorekeep-0.3.1 → lorekeep-0.5.0}/SECURITY.md +0 -0
  35. {lorekeep-0.3.1 → lorekeep-0.5.0}/cover.jpeg +0 -0
  36. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/README.md +0 -0
  37. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/architecture/agent.md +0 -0
  38. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/architecture/data-model.md +0 -0
  39. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/architecture/evaluation.md +0 -0
  40. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/architecture/journal.md +0 -0
  41. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/architecture/overview.md +0 -0
  42. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/architecture/permission.md +0 -0
  43. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/architecture/pipeline.md +0 -0
  44. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/architecture/serve-mcp.md +0 -0
  45. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/architecture/temporal.md +0 -0
  46. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/guides/backup.md +0 -0
  47. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/guides/compile.md +0 -0
  48. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/guides/data-home.md +0 -0
  49. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/guides/getting-started.md +0 -0
  50. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/guides/import.md +0 -0
  51. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/guides/serve.md +0 -0
  52. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/superpowers/plans/2026-06-26-lorekeep-dotdir-backup.md +0 -0
  53. {lorekeep-0.3.1 → lorekeep-0.5.0}/docs/superpowers/specs/2026-06-26-lorekeep-dotdir-layout-design.md +0 -0
  54. {lorekeep-0.3.1 → lorekeep-0.5.0}/release-please-config.json +0 -0
  55. {lorekeep-0.3.1 → lorekeep-0.5.0}/scripts/check-conventional-commit.py +0 -0
  56. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/agent.py +0 -0
  57. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/backup.py +0 -0
  58. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/compile/__init__.py +0 -0
  59. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/compile/extract.py +0 -0
  60. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/compile/ingest.py +0 -0
  61. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/compile/providers.py +0 -0
  62. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/compile/resolve.py +0 -0
  63. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/compile/writer.py +0 -0
  64. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/config.py +0 -0
  65. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/defaults.py +0 -0
  66. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/eval/__init__.py +0 -0
  67. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/eval/construction.py +0 -0
  68. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/eval/gold.py +0 -0
  69. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/eval/retrieval.py +0 -0
  70. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/facts_io.py +0 -0
  71. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/importer/__init__.py +0 -0
  72. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/importer/claude.py +0 -0
  73. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/importer/cursor.py +0 -0
  74. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/integrations/__init__.py +0 -0
  75. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/integrations/detect.py +0 -0
  76. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/journal.py +0 -0
  77. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/mcp_server.py +0 -0
  78. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/models.py +0 -0
  79. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/paths.py +0 -0
  80. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/perm/__init__.py +0 -0
  81. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/perm/ns.py +0 -0
  82. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/pipeline.py +0 -0
  83. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/schema_io.py +0 -0
  84. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/store/__init__.py +0 -0
  85. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/store/fts.py +0 -0
  86. {lorekeep-0.3.1 → lorekeep-0.5.0}/src/lorekeep/store/graph.py +0 -0
  87. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/conftest.py +0 -0
  88. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/fixtures/claude-session/memory/MEMORY.md +0 -0
  89. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/fixtures/claude-session/memory/lorekeep-design.md +0 -0
  90. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/fixtures/claude-session/memory/lorekeep-project-state.md +0 -0
  91. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/fixtures/claude-session/test.jsonl +0 -0
  92. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/fixtures/gold/payments.facts.jsonl +0 -0
  93. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/fixtures/raw/backend/payments.md +0 -0
  94. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/fixtures/retrieval/questions.json +0 -0
  95. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/fixtures/schema.json +0 -0
  96. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_agent_cli.py +0 -0
  97. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_agent_detect.py +0 -0
  98. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_backup.py +0 -0
  99. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_backup_cli.py +0 -0
  100. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_compile_cli.py +0 -0
  101. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_config.py +0 -0
  102. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_construction_eval.py +0 -0
  103. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_defaults.py +0 -0
  104. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_determinism.py +0 -0
  105. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_doctor_cli.py +0 -0
  106. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_eval_cli.py +0 -0
  107. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_extract.py +0 -0
  108. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_fts.py +0 -0
  109. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_graph_store.py +0 -0
  110. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_import_claude.py +0 -0
  111. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_import_cursor.py +0 -0
  112. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_ingest.py +0 -0
  113. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_init_chain.py +0 -0
  114. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_init_cli.py +0 -0
  115. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_integrations.py +0 -0
  116. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_mcp_add_cli.py +0 -0
  117. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_mcp_reload.py +0 -0
  118. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_mcp_server.py +0 -0
  119. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_mcp_write_tools.py +0 -0
  120. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_merge_journals.py +0 -0
  121. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_models.py +0 -0
  122. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_paths.py +0 -0
  123. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_perm.py +0 -0
  124. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_pipeline.py +0 -0
  125. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_providers.py +0 -0
  126. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_resolve.py +0 -0
  127. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_retrieval_eval.py +0 -0
  128. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_schema_io.py +0 -0
  129. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_serve_cli.py +0 -0
  130. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_smoke.py +0 -0
  131. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_watch_e2e.py +0 -0
  132. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_watch_sessions.py +0 -0
  133. {lorekeep-0.3.1 → lorekeep-0.5.0}/tests/test_writer.py +0 -0
  134. {lorekeep-0.3.1 → lorekeep-0.5.0}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.5.0"
3
+ }
@@ -1,5 +1,19 @@
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
+
10
+ ## [0.4.0](https://github.com/manhhailua/lorekeep/compare/v0.3.1...v0.4.0) (2026-06-28)
11
+
12
+
13
+ ### Features
14
+
15
+ * trigger memory import on Claude session end via SessionEnd hook ([#64](https://github.com/manhhailua/lorekeep/issues/64)) ([5914663](https://github.com/manhhailua/lorekeep/commit/5914663a5c02f76e1085a1f9d663066979396d77))
16
+
3
17
  ## [0.3.1](https://github.com/manhhailua/lorekeep/compare/v0.3.0...v0.3.1) (2026-06-28)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lorekeep
3
- Version: 0.3.1
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.3.1"
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.3.1"
3
+ __version__ = "0.5.0"
@@ -63,6 +63,38 @@ def version() -> None:
63
63
  typer.echo(f"lorekeep {__version__}")
64
64
 
65
65
 
66
+ @app.command(hidden=True)
67
+ def hook() -> None:
68
+ """Session lifecycle hook: quick-import memories from all agents.
69
+
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
+ """
74
+ p = resolve_paths()
75
+ total = 0
76
+
77
+ try:
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
+ except Exception:
85
+ pass
86
+
87
+ try:
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)
91
+ except Exception:
92
+ pass
93
+
94
+ if total:
95
+ typer.echo(f"lorekeep: imported {total} memory file(s)")
96
+
97
+
66
98
  @app.command()
67
99
  def compile() -> None:
68
100
  """Compile raw/ into graph/facts.jsonl."""
@@ -239,6 +271,7 @@ def mcp_add(
239
271
  p = resolve_paths()
240
272
  config = load_config(p["config"])
241
273
  command, args = resolve_command(config.install_source)
274
+ hook_cmd, hook_args = resolve_command(config.install_source, ["hook"])
242
275
 
243
276
  target = Path.cwd() if scope == "project" else Path.home()
244
277
  writers = _agent_writers()
@@ -247,6 +280,9 @@ def mcp_add(
247
280
  raise typer.Exit(code=1)
248
281
  written = writers[agent].write_config(target, command, args, ns)
249
282
  typer.echo(f"wrote {agent} config -> {written}")
283
+ if hasattr(writers[agent], "write_hook"):
284
+ hook_path = writers[agent].write_hook(target, hook_cmd, hook_args)
285
+ typer.echo(f"wrote session-end hook -> {hook_path}")
250
286
  typer.echo("\n" + agent_memory_snippet())
251
287
 
252
288
 
@@ -452,6 +488,7 @@ def _auto_wire_agents(p: dict, ns: str) -> None:
452
488
 
453
489
  config = load_config(p["config"])
454
490
  command, args = resolve_command(config.install_source)
491
+ hook_cmd, hook_args = resolve_command(config.install_source, ["hook"])
455
492
 
456
493
  writers = _agent_writers()
457
494
  target = Path.cwd()
@@ -464,6 +501,9 @@ def _auto_wire_agents(p: dict, ns: str) -> None:
464
501
  try:
465
502
  written = writer.write_config(target, command, args, ns)
466
503
  typer.echo(f" wired {agent_name} -> {written}")
504
+ if hasattr(writer, "write_hook"):
505
+ hook_path = writer.write_hook(target, hook_cmd, hook_args)
506
+ typer.echo(f" hooked {agent_name} session-end -> {hook_path}")
467
507
  except Exception as exc:
468
508
  typer.echo(f" {agent_name}: failed ({exc})")
469
509
 
@@ -614,18 +654,80 @@ def import_cmd(
614
654
  """Import knowledge from an agent's sessions into raw/.
615
655
 
616
656
  Sources:
617
- claude Claude Code sessions. --quick copies memory/*.md only (no LLM);
618
- default (deep) adds LLM-summarized transcript analysis.
619
- cursor Cursor composer conversations (GLOBAL state.vscdb). Deep-only --
620
- --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.
621
663
  """
622
- if from_source not in ("claude", "cursor"):
623
- 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)")
624
666
  raise typer.Exit(code=1)
625
667
 
626
668
  p = resolve_paths()
627
669
  config = load_config(p["config"])
628
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
+
629
731
  # --- Cursor: global composer conversations, deep-only ------------------
630
732
  if from_source == "cursor":
631
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