lorekeep 0.3.1__tar.gz → 0.4.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 (129) hide show
  1. lorekeep-0.4.0/.release-please-manifest.json +3 -0
  2. {lorekeep-0.3.1 → lorekeep-0.4.0}/CHANGELOG.md +7 -0
  3. {lorekeep-0.3.1 → lorekeep-0.4.0}/PKG-INFO +1 -1
  4. {lorekeep-0.3.1 → lorekeep-0.4.0}/pyproject.toml +1 -1
  5. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/__init__.py +1 -1
  6. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/cli.py +49 -0
  7. lorekeep-0.4.0/src/lorekeep/integrations/claude_code.py +50 -0
  8. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/integrations/common.py +10 -7
  9. lorekeep-0.4.0/src/lorekeep/integrations/cursor.py +45 -0
  10. lorekeep-0.4.0/tests/test_session_hook.py +246 -0
  11. lorekeep-0.3.1/.release-please-manifest.json +0 -3
  12. lorekeep-0.3.1/src/lorekeep/integrations/claude_code.py +0 -19
  13. lorekeep-0.3.1/src/lorekeep/integrations/cursor.py +0 -21
  14. {lorekeep-0.3.1 → lorekeep-0.4.0}/.commitlintrc.json +0 -0
  15. {lorekeep-0.3.1 → lorekeep-0.4.0}/.github/workflows/auto-merge-release-please.yml +0 -0
  16. {lorekeep-0.3.1 → lorekeep-0.4.0}/.github/workflows/ci.yml +0 -0
  17. {lorekeep-0.3.1 → lorekeep-0.4.0}/.github/workflows/lint-commits.yml +0 -0
  18. {lorekeep-0.3.1 → lorekeep-0.4.0}/.github/workflows/release-please.yml +0 -0
  19. {lorekeep-0.3.1 → lorekeep-0.4.0}/.gitignore +0 -0
  20. {lorekeep-0.3.1 → lorekeep-0.4.0}/.lorekeep/config.yaml.example +0 -0
  21. {lorekeep-0.3.1 → lorekeep-0.4.0}/.lorekeep/schema.json +0 -0
  22. {lorekeep-0.3.1 → lorekeep-0.4.0}/.pre-commit-config.yaml +0 -0
  23. {lorekeep-0.3.1 → lorekeep-0.4.0}/AGENTS.md +0 -0
  24. {lorekeep-0.3.1 → lorekeep-0.4.0}/CLAUDE.md +0 -0
  25. {lorekeep-0.3.1 → lorekeep-0.4.0}/LICENSE +0 -0
  26. {lorekeep-0.3.1 → lorekeep-0.4.0}/README.md +0 -0
  27. {lorekeep-0.3.1 → lorekeep-0.4.0}/SECURITY.md +0 -0
  28. {lorekeep-0.3.1 → lorekeep-0.4.0}/cover.jpeg +0 -0
  29. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/README.md +0 -0
  30. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/architecture/agent.md +0 -0
  31. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/architecture/data-model.md +0 -0
  32. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/architecture/evaluation.md +0 -0
  33. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/architecture/journal.md +0 -0
  34. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/architecture/overview.md +0 -0
  35. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/architecture/permission.md +0 -0
  36. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/architecture/pipeline.md +0 -0
  37. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/architecture/serve-mcp.md +0 -0
  38. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/architecture/temporal.md +0 -0
  39. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/guides/backup.md +0 -0
  40. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/guides/compile.md +0 -0
  41. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/guides/data-home.md +0 -0
  42. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/guides/getting-started.md +0 -0
  43. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/guides/import.md +0 -0
  44. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/guides/serve.md +0 -0
  45. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/superpowers/plans/2026-06-26-lorekeep-dotdir-backup.md +0 -0
  46. {lorekeep-0.3.1 → lorekeep-0.4.0}/docs/superpowers/specs/2026-06-26-lorekeep-dotdir-layout-design.md +0 -0
  47. {lorekeep-0.3.1 → lorekeep-0.4.0}/release-please-config.json +0 -0
  48. {lorekeep-0.3.1 → lorekeep-0.4.0}/scripts/check-conventional-commit.py +0 -0
  49. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/agent.py +0 -0
  50. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/backup.py +0 -0
  51. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/compile/__init__.py +0 -0
  52. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/compile/extract.py +0 -0
  53. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/compile/ingest.py +0 -0
  54. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/compile/providers.py +0 -0
  55. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/compile/resolve.py +0 -0
  56. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/compile/writer.py +0 -0
  57. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/config.py +0 -0
  58. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/defaults.py +0 -0
  59. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/eval/__init__.py +0 -0
  60. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/eval/construction.py +0 -0
  61. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/eval/gold.py +0 -0
  62. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/eval/retrieval.py +0 -0
  63. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/facts_io.py +0 -0
  64. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/importer/__init__.py +0 -0
  65. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/importer/claude.py +0 -0
  66. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/importer/cursor.py +0 -0
  67. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/integrations/__init__.py +0 -0
  68. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/integrations/codex.py +0 -0
  69. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/integrations/detect.py +0 -0
  70. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/integrations/opencode.py +0 -0
  71. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/journal.py +0 -0
  72. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/mcp_server.py +0 -0
  73. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/models.py +0 -0
  74. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/paths.py +0 -0
  75. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/perm/__init__.py +0 -0
  76. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/perm/ns.py +0 -0
  77. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/pipeline.py +0 -0
  78. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/schema_io.py +0 -0
  79. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/store/__init__.py +0 -0
  80. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/store/fts.py +0 -0
  81. {lorekeep-0.3.1 → lorekeep-0.4.0}/src/lorekeep/store/graph.py +0 -0
  82. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/conftest.py +0 -0
  83. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/fixtures/claude-session/memory/MEMORY.md +0 -0
  84. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/fixtures/claude-session/memory/lorekeep-design.md +0 -0
  85. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/fixtures/claude-session/memory/lorekeep-project-state.md +0 -0
  86. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/fixtures/claude-session/test.jsonl +0 -0
  87. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/fixtures/gold/payments.facts.jsonl +0 -0
  88. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/fixtures/raw/backend/payments.md +0 -0
  89. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/fixtures/retrieval/questions.json +0 -0
  90. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/fixtures/schema.json +0 -0
  91. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_agent_cli.py +0 -0
  92. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_agent_detect.py +0 -0
  93. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_backup.py +0 -0
  94. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_backup_cli.py +0 -0
  95. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_compile_cli.py +0 -0
  96. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_config.py +0 -0
  97. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_construction_eval.py +0 -0
  98. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_defaults.py +0 -0
  99. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_determinism.py +0 -0
  100. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_doctor_cli.py +0 -0
  101. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_eval_cli.py +0 -0
  102. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_extract.py +0 -0
  103. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_fts.py +0 -0
  104. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_graph_store.py +0 -0
  105. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_import_claude.py +0 -0
  106. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_import_cursor.py +0 -0
  107. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_ingest.py +0 -0
  108. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_init_chain.py +0 -0
  109. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_init_cli.py +0 -0
  110. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_integrations.py +0 -0
  111. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_mcp_add_cli.py +0 -0
  112. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_mcp_reload.py +0 -0
  113. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_mcp_server.py +0 -0
  114. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_mcp_write_tools.py +0 -0
  115. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_merge_journals.py +0 -0
  116. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_models.py +0 -0
  117. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_paths.py +0 -0
  118. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_perm.py +0 -0
  119. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_pipeline.py +0 -0
  120. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_providers.py +0 -0
  121. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_resolve.py +0 -0
  122. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_retrieval_eval.py +0 -0
  123. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_schema_io.py +0 -0
  124. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_serve_cli.py +0 -0
  125. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_smoke.py +0 -0
  126. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_watch_e2e.py +0 -0
  127. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_watch_sessions.py +0 -0
  128. {lorekeep-0.3.1 → lorekeep-0.4.0}/tests/test_writer.py +0 -0
  129. {lorekeep-0.3.1 → lorekeep-0.4.0}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.4.0"
3
+ }
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0](https://github.com/manhhailua/lorekeep/compare/v0.3.1...v0.4.0) (2026-06-28)
4
+
5
+
6
+ ### Features
7
+
8
+ * 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))
9
+
3
10
  ## [0.3.1](https://github.com/manhhailua/lorekeep/compare/v0.3.0...v0.3.1) (2026-06-28)
4
11
 
5
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lorekeep
3
- Version: 0.3.1
3
+ Version: 0.4.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.4.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.4.0"
@@ -63,6 +63,47 @@ def version() -> None:
63
63
  typer.echo(f"lorekeep {__version__}")
64
64
 
65
65
 
66
+ @app.command(hidden=True)
67
+ def hook() -> None:
68
+ """SessionEnd hook: quick-import Claude memory files to raw/.
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.
73
+ """
74
+ import sys
75
+
76
+ session_dir = None
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
84
+ except Exception:
85
+ pass
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
+ 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)")
103
+ except Exception:
104
+ pass
105
+
106
+
66
107
  @app.command()
67
108
  def compile() -> None:
68
109
  """Compile raw/ into graph/facts.jsonl."""
@@ -239,6 +280,7 @@ def mcp_add(
239
280
  p = resolve_paths()
240
281
  config = load_config(p["config"])
241
282
  command, args = resolve_command(config.install_source)
283
+ hook_cmd, hook_args = resolve_command(config.install_source, ["hook"])
242
284
 
243
285
  target = Path.cwd() if scope == "project" else Path.home()
244
286
  writers = _agent_writers()
@@ -247,6 +289,9 @@ def mcp_add(
247
289
  raise typer.Exit(code=1)
248
290
  written = writers[agent].write_config(target, command, args, ns)
249
291
  typer.echo(f"wrote {agent} config -> {written}")
292
+ if agent in ("claude", "cursor") and hasattr(writers[agent], "write_hook"):
293
+ hook_path = writers[agent].write_hook(target, hook_cmd, hook_args)
294
+ typer.echo(f"wrote session-end hook -> {hook_path}")
250
295
  typer.echo("\n" + agent_memory_snippet())
251
296
 
252
297
 
@@ -452,6 +497,7 @@ def _auto_wire_agents(p: dict, ns: str) -> None:
452
497
 
453
498
  config = load_config(p["config"])
454
499
  command, args = resolve_command(config.install_source)
500
+ hook_cmd, hook_args = resolve_command(config.install_source, ["hook"])
455
501
 
456
502
  writers = _agent_writers()
457
503
  target = Path.cwd()
@@ -464,6 +510,9 @@ def _auto_wire_agents(p: dict, ns: str) -> None:
464
510
  try:
465
511
  written = writer.write_config(target, command, args, ns)
466
512
  typer.echo(f" wired {agent_name} -> {written}")
513
+ if agent_name in ("claude", "cursor") and hasattr(writer, "write_hook"):
514
+ hook_path = writer.write_hook(target, hook_cmd, hook_args)
515
+ typer.echo(f" hooked {agent_name} session-end -> {hook_path}")
467
516
  except Exception as exc:
468
517
  typer.echo(f" {agent_name}: failed ({exc})")
469
518
 
@@ -0,0 +1,50 @@
1
+ """Claude Code MCP config writer (.mcp.json) + SessionEnd hook writer."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+
8
+ def write_config(target_dir: Path, command: str, args: list[str], ns: str | None) -> Path:
9
+ entry = {"command": command, "args": args}
10
+ if ns:
11
+ entry["env"] = {"LOREKEEP_NS": ns}
12
+ path = Path(target_dir) / ".mcp.json"
13
+ existing = {}
14
+ if path.exists():
15
+ existing = json.loads(path.read_text())
16
+ servers = existing.get("mcpServers", {})
17
+ servers["lorekeep"] = entry
18
+ path.write_text(json.dumps({"mcpServers": servers}, indent=2))
19
+ return path
20
+
21
+
22
+ def write_hook(target_dir: Path, command: str, args: list[str]) -> Path:
23
+ """Write a SessionEnd hook to .claude/settings.json.
24
+
25
+ The hook calls ``lorekeep hook`` which quick-imports Claude memory
26
+ files into raw/ on session end. The daemon (if running) picks up the
27
+ raw/ change and compiles automatically.
28
+ """
29
+ settings_path = Path(target_dir) / ".claude" / "settings.json"
30
+ existing = {}
31
+ if settings_path.exists():
32
+ try:
33
+ existing = json.loads(settings_path.read_text())
34
+ except (json.JSONDecodeError, OSError):
35
+ pass
36
+
37
+ hooks = existing.get("hooks", {})
38
+ hooks["SessionEnd"] = [{
39
+ "hooks": [{
40
+ "type": "command",
41
+ "command": command,
42
+ "args": args,
43
+ "timeout": 30,
44
+ }]
45
+ }]
46
+ existing["hooks"] = hooks
47
+
48
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
49
+ settings_path.write_text(json.dumps(existing, indent=2))
50
+ return settings_path
@@ -2,15 +2,18 @@
2
2
  from __future__ import annotations
3
3
 
4
4
 
5
- def resolve_command(install_source: str | None) -> tuple[str, list[str]]:
6
- """Return (command, args) to launch `lorekeep serve --transport stdio`."""
7
- serve_args = ["serve", "--transport", "stdio"]
5
+ def resolve_command(install_source: str | None, subcommand: list[str] | None = None) -> tuple[str, list[str]]:
6
+ """Return (command, args) to launch a lorekeep subcommand.
7
+
8
+ Defaults to ``serve --transport stdio``. Pass ``subcommand`` for others
9
+ (e.g. ``["hook"]``).
10
+ """
11
+ cmd_args = subcommand or ["serve", "--transport", "stdio"]
8
12
  if not install_source or install_source == "pypi":
9
- return ("uvx", ["lorekeep", *serve_args])
13
+ return ("uvx", ["lorekeep", *cmd_args])
10
14
  if install_source == "local":
11
- return ("lorekeep", serve_args)
12
- # anything else (git+URL, local path) -> uvx --from <source>
13
- return ("uvx", ["--from", install_source, "lorekeep", *serve_args])
15
+ return ("lorekeep", cmd_args)
16
+ return ("uvx", ["--from", install_source, "lorekeep", *cmd_args])
14
17
 
15
18
 
16
19
  def agent_memory_snippet() -> str:
@@ -0,0 +1,45 @@
1
+ """Cursor MCP config writer (.cursor/mcp.json) + sessionEnd hook writer."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+
8
+ def write_config(target_dir: Path, command: str, args: list[str], ns: str | None) -> Path:
9
+ entry = {"command": command, "args": args}
10
+ if ns:
11
+ entry["env"] = {"LOREKEEP_NS": ns}
12
+ d = Path(target_dir) / ".cursor"
13
+ d.mkdir(parents=True, exist_ok=True)
14
+ path = d / "mcp.json"
15
+ existing = {}
16
+ if path.exists():
17
+ existing = json.loads(path.read_text())
18
+ servers = existing.get("mcpServers", {})
19
+ servers["lorekeep"] = entry
20
+ path.write_text(json.dumps({"mcpServers": servers}, indent=2))
21
+ return path
22
+
23
+
24
+ def write_hook(target_dir: Path, command: str, args: list[str]) -> Path:
25
+ """Write a sessionEnd hook to .cursor/hooks.json.
26
+
27
+ Cursor's hook format uses a single command string (shell form).
28
+ """
29
+ cmd_str = " ".join([command, *args])
30
+ path = Path(target_dir) / ".cursor" / "hooks.json"
31
+ existing = {}
32
+ if path.exists():
33
+ try:
34
+ existing = json.loads(path.read_text())
35
+ except (json.JSONDecodeError, OSError):
36
+ pass
37
+
38
+ hooks = existing.get("hooks", {})
39
+ hooks["sessionEnd"] = [{"command": cmd_str, "timeout": 30}]
40
+ existing["version"] = existing.get("version", 1)
41
+ existing["hooks"] = hooks
42
+
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ path.write_text(json.dumps(existing, indent=2))
45
+ return path
@@ -0,0 +1,246 @@
1
+ """Tests for SessionEnd hook: lorekeep hook command + .claude/settings.json wiring."""
2
+ import json
3
+ import shutil
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from typer.testing import CliRunner
8
+
9
+ import lorekeep.mcp_server as ms
10
+ from lorekeep.cli import app
11
+ from lorekeep.integrations.claude_code import write_config, write_hook
12
+
13
+ runner = CliRunner()
14
+
15
+
16
+ # ── lorekeep hook command ─────────────────────────────────────────────────
17
+
18
+
19
+ def test_hook_imports_memory_from_stdin(tmp_path: Path, monkeypatch):
20
+ """lorekeep hook reads transcript_path from stdin, imports memory files."""
21
+ home = tmp_path / "home"
22
+ home.mkdir()
23
+ (home / "raw").mkdir()
24
+ (home / "graph").mkdir()
25
+ (home / "pending").mkdir()
26
+ (home / "cache.json").write_text("{}")
27
+
28
+ session_dir = tmp_path / "session"
29
+ (session_dir / "memory").mkdir(parents=True)
30
+ (session_dir / "memory" / "note.md").write_text("# Important\nKnowledge.\n")
31
+ transcript = session_dir / "transcript.jsonl"
32
+ transcript.write_text("{}")
33
+
34
+ monkeypatch.setenv("LOREKEEP_HOME", str(home))
35
+
36
+ hook_input = json.dumps({"transcript_path": str(transcript)})
37
+ result = runner.invoke(app, ["hook"], input=hook_input)
38
+ assert result.exit_code == 0, result.stdout
39
+
40
+ imported = home / "raw" / "claude-memory" / "note.md"
41
+ assert imported.exists()
42
+ assert "imported" in result.stdout.lower()
43
+
44
+
45
+ def test_hook_no_session_silent_exit(tmp_path: Path, monkeypatch):
46
+ """lorekeep hook exits silently when no session found."""
47
+ home = tmp_path / "home"
48
+ home.mkdir()
49
+ (home / "raw").mkdir()
50
+ (home / "graph").mkdir()
51
+ monkeypatch.setenv("LOREKEEP_HOME", str(home))
52
+ monkeypatch.setattr(
53
+ "lorekeep.importer.claude.find_current_session", lambda: None
54
+ )
55
+
56
+ result = runner.invoke(app, ["hook"], input="")
57
+ assert result.exit_code == 0
58
+
59
+
60
+ def test_hook_no_memory_dir_silent_exit(tmp_path: Path, monkeypatch):
61
+ """lorekeep hook exits silently when session has no memory/ dir."""
62
+ home = tmp_path / "home"
63
+ home.mkdir()
64
+ (home / "raw").mkdir()
65
+ monkeypatch.setenv("LOREKEEP_HOME", str(home))
66
+
67
+ session_dir = tmp_path / "session"
68
+ session_dir.mkdir()
69
+ transcript = session_dir / "transcript.jsonl"
70
+ transcript.write_text("{}")
71
+
72
+ hook_input = json.dumps({"transcript_path": str(transcript)})
73
+ result = runner.invoke(app, ["hook"], input=hook_input)
74
+ assert result.exit_code == 0
75
+
76
+
77
+ def test_hook_idempotent(tmp_path: Path, monkeypatch):
78
+ """Running hook twice doesn't re-import unchanged files."""
79
+ home = tmp_path / "home"
80
+ home.mkdir()
81
+ (home / "raw").mkdir()
82
+ (home / "graph").mkdir()
83
+ (home / "pending").mkdir()
84
+ (home / "cache.json").write_text("{}")
85
+
86
+ session_dir = tmp_path / "session"
87
+ (session_dir / "memory").mkdir(parents=True)
88
+ (session_dir / "memory" / "note.md").write_text("# Knowledge\n")
89
+ transcript = session_dir / "transcript.jsonl"
90
+ transcript.write_text("{}")
91
+
92
+ monkeypatch.setenv("LOREKEEP_HOME", str(home))
93
+
94
+ hook_input = json.dumps({"transcript_path": str(transcript)})
95
+ runner.invoke(app, ["hook"], input=hook_input)
96
+ result2 = runner.invoke(app, ["hook"], input=hook_input)
97
+ assert result2.exit_code == 0
98
+ assert "imported" not in result2.stdout.lower()
99
+
100
+
101
+ # ── write_hook (settings.json) ────────────────────────────────────────────
102
+
103
+
104
+ def test_write_hook_creates_settings(tmp_path: Path):
105
+ """write_hook creates .claude/settings.json with SessionEnd hook."""
106
+ cmd = "uvx"
107
+ args = ["lorekeep", "hook"]
108
+
109
+ path = write_hook(tmp_path, cmd, args)
110
+ assert path == tmp_path / ".claude" / "settings.json"
111
+ assert path.exists()
112
+
113
+ settings = json.loads(path.read_text())
114
+ hooks = settings["hooks"]["SessionEnd"]
115
+ assert len(hooks) == 1
116
+ handler = hooks[0]["hooks"][0]
117
+ assert handler["type"] == "command"
118
+ assert handler["command"] == "uvx"
119
+ assert handler["args"] == ["lorekeep", "hook"]
120
+ assert handler["timeout"] == 30
121
+
122
+
123
+ def test_write_hook_preserves_existing_settings(tmp_path: Path):
124
+ """write_hook preserves existing settings keys."""
125
+ settings_path = tmp_path / ".claude" / "settings.json"
126
+ settings_path.parent.mkdir(parents=True)
127
+ settings_path.write_text(json.dumps({
128
+ "permissions": {"allow": ["Read"]},
129
+ "hooks": {"PreToolUse": [{"hooks": [{"type": "command", "command": "echo hi"}]}]},
130
+ }))
131
+
132
+ write_hook(tmp_path, "uvx", ["lorekeep", "hook"])
133
+
134
+ settings = json.loads(settings_path.read_text())
135
+ assert settings["permissions"]["allow"] == ["Read"]
136
+ assert "PreToolUse" in settings["hooks"]
137
+ assert "SessionEnd" in settings["hooks"]
138
+
139
+
140
+ # ── Init wires hook for Claude ─────────────────────────────────────────────
141
+
142
+
143
+ def test_init_writes_claude_hook(tmp_path: Path, monkeypatch):
144
+ """init should write SessionEnd hook when Claude is detected."""
145
+ home = tmp_path / "home"
146
+ project = tmp_path / "project"
147
+ fake_home = tmp_path / "fakehome"
148
+ project.mkdir()
149
+ fake_home.mkdir()
150
+ (fake_home / ".claude").mkdir(parents=True)
151
+
152
+ monkeypatch.setenv("LOREKEEP_HOME", str(home))
153
+ monkeypatch.setenv("HOME", str(fake_home))
154
+ monkeypatch.setenv("LOREKEEP_DEV", "0")
155
+ monkeypatch.chdir(project)
156
+ monkeypatch.setattr("pathlib.Path.home", lambda: fake_home)
157
+ monkeypatch.setattr("lorekeep.integrations.detect.shutil.which", lambda _: None)
158
+ monkeypatch.delenv("OPENCODE", raising=False)
159
+ monkeypatch.delenv("CLAUDECODE", raising=False)
160
+
161
+ result = runner.invoke(app, ["init", "--yes", "--no-watch"])
162
+ assert result.exit_code == 0, result.stdout
163
+
164
+ settings = project / ".claude" / "settings.json"
165
+ assert settings.exists(), f"settings.json not written: {result.stdout}"
166
+ data = json.loads(settings.read_text())
167
+ assert "SessionEnd" in data["hooks"]
168
+
169
+
170
+ # ── mcp add wires hook for Claude ──────────────────────────────────────────
171
+
172
+
173
+ def test_mcp_add_writes_claude_hook(tmp_path: Path, monkeypatch):
174
+ """mcp add --agent claude should also write the SessionEnd hook."""
175
+ home = tmp_path / "home"
176
+ project = tmp_path / "project"
177
+ project.mkdir()
178
+ home.mkdir()
179
+ (home / "config.yaml").write_text("install_source: pypi\n")
180
+
181
+ monkeypatch.setenv("LOREKEEP_HOME", str(home))
182
+ monkeypatch.chdir(project)
183
+
184
+ result = runner.invoke(app, ["mcp", "add", "--agent", "claude", "--ns", "backend"])
185
+ assert result.exit_code == 0, result.stdout
186
+
187
+ settings = project / ".claude" / "settings.json"
188
+ assert settings.exists()
189
+ data = json.loads(settings.read_text())
190
+ assert "SessionEnd" in data["hooks"]
191
+
192
+
193
+ def test_mcp_add_opencode_no_hook(tmp_path: Path, monkeypatch):
194
+ """mcp add --agent opencode should NOT write a hook (no hook mechanism)."""
195
+ home = tmp_path / "home"
196
+ project = tmp_path / "project"
197
+ project.mkdir()
198
+ home.mkdir()
199
+ (home / "config.yaml").write_text("install_source: pypi\n")
200
+
201
+ monkeypatch.setenv("LOREKEEP_HOME", str(home))
202
+ monkeypatch.chdir(project)
203
+
204
+ result = runner.invoke(app, ["mcp", "add", "--agent", "opencode", "--ns", "backend"])
205
+ assert result.exit_code == 0, result.stdout
206
+
207
+ assert not (project / ".claude" / "settings.json").exists()
208
+
209
+
210
+ # ── Cursor hook ───────────────────────────────────────────────────────────
211
+
212
+
213
+ def test_write_cursor_hook(tmp_path: Path):
214
+ """write_hook for Cursor creates .cursor/hooks.json with sessionEnd."""
215
+ from lorekeep.integrations.cursor import write_hook as write_cursor_hook
216
+
217
+ path = write_cursor_hook(tmp_path, "uvx", ["lorekeep", "hook"])
218
+ assert path == tmp_path / ".cursor" / "hooks.json"
219
+ assert path.exists()
220
+
221
+ data = json.loads(path.read_text())
222
+ assert data["version"] == 1
223
+ hooks = data["hooks"]["sessionEnd"]
224
+ assert len(hooks) == 1
225
+ assert hooks[0]["command"] == "uvx lorekeep hook"
226
+ assert hooks[0]["timeout"] == 30
227
+
228
+
229
+ def test_mcp_add_cursor_hook(tmp_path: Path, monkeypatch):
230
+ """mcp add --agent cursor should write sessionEnd hook."""
231
+ home = tmp_path / "home"
232
+ project = tmp_path / "project"
233
+ project.mkdir()
234
+ home.mkdir()
235
+ (home / "config.yaml").write_text("install_source: pypi\n")
236
+
237
+ monkeypatch.setenv("LOREKEEP_HOME", str(home))
238
+ monkeypatch.chdir(project)
239
+
240
+ result = runner.invoke(app, ["mcp", "add", "--agent", "cursor", "--ns", "backend"])
241
+ assert result.exit_code == 0, result.stdout
242
+
243
+ hooks_path = project / ".cursor" / "hooks.json"
244
+ assert hooks_path.exists()
245
+ data = json.loads(hooks_path.read_text())
246
+ assert "sessionEnd" in data["hooks"]
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.3.1"
3
- }
@@ -1,19 +0,0 @@
1
- """Claude Code MCP config writer (.mcp.json)."""
2
- from __future__ import annotations
3
-
4
- import json
5
- from pathlib import Path
6
-
7
-
8
- def write_config(target_dir: Path, command: str, args: list[str], ns: str | None) -> Path:
9
- entry = {"command": command, "args": args}
10
- if ns:
11
- entry["env"] = {"LOREKEEP_NS": ns}
12
- path = Path(target_dir) / ".mcp.json"
13
- existing = {}
14
- if path.exists():
15
- existing = json.loads(path.read_text())
16
- servers = existing.get("mcpServers", {})
17
- servers["lorekeep"] = entry
18
- path.write_text(json.dumps({"mcpServers": servers}, indent=2))
19
- return path
@@ -1,21 +0,0 @@
1
- """Cursor MCP config writer (.cursor/mcp.json)."""
2
- from __future__ import annotations
3
-
4
- import json
5
- from pathlib import Path
6
-
7
-
8
- def write_config(target_dir: Path, command: str, args: list[str], ns: str | None) -> Path:
9
- entry = {"command": command, "args": args}
10
- if ns:
11
- entry["env"] = {"LOREKEEP_NS": ns}
12
- d = Path(target_dir) / ".cursor"
13
- d.mkdir(parents=True, exist_ok=True)
14
- path = d / "mcp.json"
15
- existing = {}
16
- if path.exists():
17
- existing = json.loads(path.read_text())
18
- servers = existing.get("mcpServers", {})
19
- servers["lorekeep"] = entry
20
- path.write_text(json.dumps({"mcpServers": servers}, indent=2))
21
- return path
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes