lorekeep 0.7.0__tar.gz → 0.8.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 (138) hide show
  1. lorekeep-0.8.0/.release-please-manifest.json +3 -0
  2. {lorekeep-0.7.0 → lorekeep-0.8.0}/AGENTS.md +1 -0
  3. {lorekeep-0.7.0 → lorekeep-0.8.0}/CHANGELOG.md +14 -0
  4. {lorekeep-0.7.0 → lorekeep-0.8.0}/PKG-INFO +26 -7
  5. {lorekeep-0.7.0 → lorekeep-0.8.0}/README.md +25 -6
  6. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/evaluation.md +12 -3
  7. {lorekeep-0.7.0 → lorekeep-0.8.0}/pyproject.toml +1 -1
  8. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/__init__.py +1 -1
  9. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/cli.py +55 -0
  10. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/compile/extract.py +5 -2
  11. lorekeep-0.8.0/src/lorekeep/eval/locomo.py +360 -0
  12. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/mcp_server.py +19 -4
  13. lorekeep-0.8.0/tests/test_fts.py +109 -0
  14. lorekeep-0.8.0/tests/test_locomo_eval.py +314 -0
  15. lorekeep-0.7.0/.release-please-manifest.json +0 -3
  16. lorekeep-0.7.0/tests/test_fts.py +0 -31
  17. {lorekeep-0.7.0 → lorekeep-0.8.0}/.commitlintrc.json +0 -0
  18. {lorekeep-0.7.0 → lorekeep-0.8.0}/.github/workflows/auto-merge-release-please.yml +0 -0
  19. {lorekeep-0.7.0 → lorekeep-0.8.0}/.github/workflows/ci.yml +0 -0
  20. {lorekeep-0.7.0 → lorekeep-0.8.0}/.github/workflows/lint-commits.yml +0 -0
  21. {lorekeep-0.7.0 → lorekeep-0.8.0}/.github/workflows/release-please.yml +0 -0
  22. {lorekeep-0.7.0 → lorekeep-0.8.0}/.gitignore +0 -0
  23. {lorekeep-0.7.0 → lorekeep-0.8.0}/.lorekeep/config.yaml.example +0 -0
  24. {lorekeep-0.7.0 → lorekeep-0.8.0}/.lorekeep/schema.json +0 -0
  25. {lorekeep-0.7.0 → lorekeep-0.8.0}/.pre-commit-config.yaml +0 -0
  26. {lorekeep-0.7.0 → lorekeep-0.8.0}/CLAUDE.md +0 -0
  27. {lorekeep-0.7.0 → lorekeep-0.8.0}/LICENSE +0 -0
  28. {lorekeep-0.7.0 → lorekeep-0.8.0}/SECURITY.md +0 -0
  29. {lorekeep-0.7.0 → lorekeep-0.8.0}/cover.jpeg +0 -0
  30. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/README.md +0 -0
  31. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/agent.md +0 -0
  32. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/data-model.md +0 -0
  33. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/journal.md +0 -0
  34. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/overview.md +0 -0
  35. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/permission.md +0 -0
  36. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/pipeline.md +0 -0
  37. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/serve-mcp.md +0 -0
  38. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/temporal.md +0 -0
  39. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/guides/backup.md +0 -0
  40. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/guides/compile.md +0 -0
  41. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/guides/data-home.md +0 -0
  42. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/guides/getting-started.md +0 -0
  43. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/guides/import.md +0 -0
  44. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/guides/serve.md +0 -0
  45. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/guides/wiki.md +0 -0
  46. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/superpowers/plans/2026-06-26-lorekeep-dotdir-backup.md +0 -0
  47. {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/superpowers/specs/2026-06-26-lorekeep-dotdir-layout-design.md +0 -0
  48. {lorekeep-0.7.0 → lorekeep-0.8.0}/release-please-config.json +0 -0
  49. {lorekeep-0.7.0 → lorekeep-0.8.0}/scripts/check-conventional-commit.py +0 -0
  50. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/agent.py +0 -0
  51. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/backup.py +0 -0
  52. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/compile/__init__.py +0 -0
  53. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/compile/ingest.py +0 -0
  54. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/compile/providers.py +0 -0
  55. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/compile/resolve.py +0 -0
  56. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/compile/writer.py +0 -0
  57. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/config.py +0 -0
  58. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/defaults.py +0 -0
  59. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/eval/__init__.py +0 -0
  60. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/eval/construction.py +0 -0
  61. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/eval/gold.py +0 -0
  62. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/eval/retrieval.py +0 -0
  63. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/facts_io.py +0 -0
  64. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/importer/__init__.py +0 -0
  65. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/importer/claude.py +0 -0
  66. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/importer/codex.py +0 -0
  67. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/importer/cursor.py +0 -0
  68. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/importer/opencode.py +0 -0
  69. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/integrations/__init__.py +0 -0
  70. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/integrations/claude_code.py +0 -0
  71. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/integrations/codex.py +0 -0
  72. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/integrations/common.py +0 -0
  73. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/integrations/cursor.py +0 -0
  74. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/integrations/detect.py +0 -0
  75. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/integrations/opencode.py +0 -0
  76. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/journal.py +0 -0
  77. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/models.py +0 -0
  78. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/paths.py +0 -0
  79. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/perm/__init__.py +0 -0
  80. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/perm/ns.py +0 -0
  81. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/pipeline.py +0 -0
  82. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/schema_io.py +0 -0
  83. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/store/__init__.py +0 -0
  84. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/store/fts.py +0 -0
  85. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/store/graph.py +0 -0
  86. {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/wiki.py +0 -0
  87. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/conftest.py +0 -0
  88. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/fixtures/claude-session/memory/MEMORY.md +0 -0
  89. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/fixtures/claude-session/memory/lorekeep-design.md +0 -0
  90. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/fixtures/claude-session/memory/lorekeep-project-state.md +0 -0
  91. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/fixtures/claude-session/test.jsonl +0 -0
  92. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/fixtures/gold/payments.facts.jsonl +0 -0
  93. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/fixtures/raw/backend/payments.md +0 -0
  94. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/fixtures/retrieval/questions.json +0 -0
  95. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/fixtures/schema.json +0 -0
  96. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_agent_cli.py +0 -0
  97. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_agent_detect.py +0 -0
  98. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_backup.py +0 -0
  99. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_backup_cli.py +0 -0
  100. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_compile_cli.py +0 -0
  101. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_config.py +0 -0
  102. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_construction_eval.py +0 -0
  103. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_defaults.py +0 -0
  104. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_determinism.py +0 -0
  105. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_doctor_cli.py +0 -0
  106. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_eval_cli.py +0 -0
  107. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_extract.py +0 -0
  108. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_graph_store.py +0 -0
  109. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_import_claude.py +0 -0
  110. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_import_codex.py +0 -0
  111. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_import_cursor.py +0 -0
  112. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_import_opencode.py +0 -0
  113. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_ingest.py +0 -0
  114. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_init_chain.py +0 -0
  115. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_init_cli.py +0 -0
  116. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_integrations.py +0 -0
  117. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_mcp_add_cli.py +0 -0
  118. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_mcp_reload.py +0 -0
  119. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_mcp_server.py +0 -0
  120. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_mcp_write_tools.py +0 -0
  121. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_merge_journals.py +0 -0
  122. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_models.py +0 -0
  123. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_paths.py +0 -0
  124. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_perm.py +0 -0
  125. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_pipeline.py +0 -0
  126. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_providers.py +0 -0
  127. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_resolve.py +0 -0
  128. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_retrieval_eval.py +0 -0
  129. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_schema_io.py +0 -0
  130. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_scope_awareness.py +0 -0
  131. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_serve_cli.py +0 -0
  132. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_session_hook.py +0 -0
  133. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_smoke.py +0 -0
  134. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_watch_e2e.py +0 -0
  135. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_watch_sessions.py +0 -0
  136. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_wiki.py +0 -0
  137. {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_writer.py +0 -0
  138. {lorekeep-0.7.0 → lorekeep-0.8.0}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.8.0"
3
+ }
@@ -28,6 +28,7 @@ uv run lorekeep <command> # run the CLI in dev mode
28
28
  | `compile` | `raw/*.md` → `graph/facts.jsonl` + `manifest.json` + `wiki/` (runs the LLM pipeline, auto-generates wiki) |
29
29
  | `check` | Validate compiled graph loads, no dangling edges (exit 1 on failure) |
30
30
  | `eval` | Tier-1 construction P/R/F1 vs gold corpus + structure metrics |
31
+ | `eval-locomo` | Tier-2 LoCoMo retrieval/temporal/abstention eval |
31
32
  | `wiki` | Regenerate `wiki/` from `facts.jsonl` (Obsidian-compatible markdown) |
32
33
  | `serve [--transport stdio\|http]` | Run the MCP server (9 read + 5 write tools) |
33
34
  | `mcp add --agent claude\|cursor\|codex\|opencode --ns NS` | Write agent MCP config |
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.0](https://github.com/manhhailua/lorekeep/compare/v0.7.1...v0.8.0) (2026-06-30)
4
+
5
+
6
+ ### Features
7
+
8
+ * add Tier-2 LoCoMo retrieval eval ([#76](https://github.com/manhhailua/lorekeep/issues/76)) ([a285409](https://github.com/manhhailua/lorekeep/commit/a285409f62584443907491c4cdb517e9dd4b9eea))
9
+
10
+ ## [0.7.1](https://github.com/manhhailua/lorekeep/compare/v0.7.0...v0.7.1) (2026-06-30)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * wire FTS5 search index + include model in extraction cache key ([#74](https://github.com/manhhailua/lorekeep/issues/74)) ([e9cd60a](https://github.com/manhhailua/lorekeep/commit/e9cd60ab573ae42b172f888cf3af5647e747c3f7))
16
+
3
17
  ## [0.7.0](https://github.com/manhhailua/lorekeep/compare/v0.6.1...v0.7.0) (2026-06-30)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lorekeep
3
- Version: 0.7.0
3
+ Version: 0.8.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
@@ -83,6 +83,8 @@ knowledge.
83
83
  DashScope/Qwen / Ollama). Strict-privacy → Ollama, fully local.
84
84
  - **Tier-1 eval** — extraction P/R/F1 vs a gold corpus, entity-resolution F1,
85
85
  graph-structure metrics, determinism property tests.
86
+ - **Tier-2 LoCoMo eval** — long-term conversations → graph → retrieval QA.
87
+ F1 per category (single-hop, temporal, multi-hop, adversarial abstention).
86
88
 
87
89
  ## Install
88
90
 
@@ -250,11 +252,27 @@ For usage, see the [`docs/`](docs/README.md) index.
250
252
 
251
253
  ## Evaluation
252
254
 
253
- Tier-1 (CI): extraction P/R/F1 vs a gold corpus, entity-resolution pairwise F1,
254
- graph-structure metrics, determinism. Run: `uvx lorekeep eval`. The north star is
255
- *systematic thinking with complete information* — memory-recall benchmarks
256
- (LoCoMo, LongMemEval) are parity checks, not the optimization target. See
257
- [`docs/architecture/evaluation.md`](docs/architecture/evaluation.md).
255
+ **Tier-1** (CI): extraction P/R/F1 vs a gold corpus, entity-resolution pairwise F1,
256
+ graph-structure metrics, determinism. Run: `uvx lorekeep eval`.
257
+
258
+ **Tier-2 LoCoMo** (per-release): long-term conversation → graph → retrieval QA.
259
+ Run: `uvx lorekeep eval-locomo --data locomo10.json --compile`.
260
+
261
+ | Category | Recall | Description |
262
+ |---|---|---|
263
+ | Temporal | 0.71 | "When did X happen?" |
264
+ | Single-hop | 0.58 | "What is X's Y?" |
265
+ | Multi-hop | 0.49 | "Who did X's sister work with?" |
266
+ | Descriptive | 0.73 | "What did X do?" |
267
+ | Adversarial | 0.85 | Abstention — plausible-but-wrong answer not found |
268
+ | **Overall** | **0.71** | 199 QA, 1 conversation, DeepSeek extraction |
269
+
270
+ *Graph-guided retrieval: keyword search → get_node → neighbors(depth=1-2) →
271
+ source markdown enrichment. No agent-LLM synthesis (programmatic baseline).*
272
+
273
+ The north star is *systematic thinking with complete information* — memory-recall
274
+ benchmarks (LoCoMo, LongMemEval) are parity checks, not the optimization target.
275
+ See [`docs/architecture/evaluation.md`](docs/architecture/evaluation.md).
258
276
 
259
277
  ## Project layout
260
278
 
@@ -277,6 +295,7 @@ src/lorekeep/
277
295
  integrations/{claude_code,cursor,codex,opencode,common}.py
278
296
  pipeline.py, cli.py
279
297
  eval/{gold,construction,retrieval}.py
298
+ eval/locomo.py Tier-2 LoCoMo eval
280
299
  tests/ ~310 tests
281
300
  docs/ README.md index, architecture/, guides/
282
301
  ```
@@ -285,7 +304,7 @@ docs/ README.md index, architecture/, guides/
285
304
 
286
305
  **v1 (implemented)** — compile pipeline + serve (store/permission/MCP 9 read+5 write/4-agent integrations) + import (Claude/Cursor/Codex/opencode) + session-end hooks + agent daemon (watch/ingest/lint/suggest/status) + journal + resolve + data-home + dev mode + lazy-reload + backup + eval + scope awareness (`meta` tool) + **wiki** (Obsidian-compatible markdown output). Published to PyPI as `lorekeep`.
287
306
 
288
- **Phase 2 (planned)** — streamable-HTTP team server, OIDC/SSO, embeddings/hybrid search, scheduled nightly lint/suggest in daemon, schema evolve, full Tier-2 benchmark datasets (HotpotQA/CronQuestions) and the bespoke Tier-3 Lorekeep-Reason eval.
307
+ **Phase 2 (planned)** — streamable-HTTP team server, OIDC/SSO, embeddings/hybrid search, scheduled nightly lint/suggest in daemon, schema evolve, HotpotQA/CronQuestions/LongMemEval benchmark datasets and the bespoke Tier-3 Lorekeep-Reason eval.
289
308
 
290
309
  ## Documentation
291
310
 
@@ -61,6 +61,8 @@ knowledge.
61
61
  DashScope/Qwen / Ollama). Strict-privacy → Ollama, fully local.
62
62
  - **Tier-1 eval** — extraction P/R/F1 vs a gold corpus, entity-resolution F1,
63
63
  graph-structure metrics, determinism property tests.
64
+ - **Tier-2 LoCoMo eval** — long-term conversations → graph → retrieval QA.
65
+ F1 per category (single-hop, temporal, multi-hop, adversarial abstention).
64
66
 
65
67
  ## Install
66
68
 
@@ -228,11 +230,27 @@ For usage, see the [`docs/`](docs/README.md) index.
228
230
 
229
231
  ## Evaluation
230
232
 
231
- Tier-1 (CI): extraction P/R/F1 vs a gold corpus, entity-resolution pairwise F1,
232
- graph-structure metrics, determinism. Run: `uvx lorekeep eval`. The north star is
233
- *systematic thinking with complete information* — memory-recall benchmarks
234
- (LoCoMo, LongMemEval) are parity checks, not the optimization target. See
235
- [`docs/architecture/evaluation.md`](docs/architecture/evaluation.md).
233
+ **Tier-1** (CI): extraction P/R/F1 vs a gold corpus, entity-resolution pairwise F1,
234
+ graph-structure metrics, determinism. Run: `uvx lorekeep eval`.
235
+
236
+ **Tier-2 LoCoMo** (per-release): long-term conversation → graph → retrieval QA.
237
+ Run: `uvx lorekeep eval-locomo --data locomo10.json --compile`.
238
+
239
+ | Category | Recall | Description |
240
+ |---|---|---|
241
+ | Temporal | 0.71 | "When did X happen?" |
242
+ | Single-hop | 0.58 | "What is X's Y?" |
243
+ | Multi-hop | 0.49 | "Who did X's sister work with?" |
244
+ | Descriptive | 0.73 | "What did X do?" |
245
+ | Adversarial | 0.85 | Abstention — plausible-but-wrong answer not found |
246
+ | **Overall** | **0.71** | 199 QA, 1 conversation, DeepSeek extraction |
247
+
248
+ *Graph-guided retrieval: keyword search → get_node → neighbors(depth=1-2) →
249
+ source markdown enrichment. No agent-LLM synthesis (programmatic baseline).*
250
+
251
+ The north star is *systematic thinking with complete information* — memory-recall
252
+ benchmarks (LoCoMo, LongMemEval) are parity checks, not the optimization target.
253
+ See [`docs/architecture/evaluation.md`](docs/architecture/evaluation.md).
236
254
 
237
255
  ## Project layout
238
256
 
@@ -255,6 +273,7 @@ src/lorekeep/
255
273
  integrations/{claude_code,cursor,codex,opencode,common}.py
256
274
  pipeline.py, cli.py
257
275
  eval/{gold,construction,retrieval}.py
276
+ eval/locomo.py Tier-2 LoCoMo eval
258
277
  tests/ ~310 tests
259
278
  docs/ README.md index, architecture/, guides/
260
279
  ```
@@ -263,7 +282,7 @@ docs/ README.md index, architecture/, guides/
263
282
 
264
283
  **v1 (implemented)** — compile pipeline + serve (store/permission/MCP 9 read+5 write/4-agent integrations) + import (Claude/Cursor/Codex/opencode) + session-end hooks + agent daemon (watch/ingest/lint/suggest/status) + journal + resolve + data-home + dev mode + lazy-reload + backup + eval + scope awareness (`meta` tool) + **wiki** (Obsidian-compatible markdown output). Published to PyPI as `lorekeep`.
265
284
 
266
- **Phase 2 (planned)** — streamable-HTTP team server, OIDC/SSO, embeddings/hybrid search, scheduled nightly lint/suggest in daemon, schema evolve, full Tier-2 benchmark datasets (HotpotQA/CronQuestions) and the bespoke Tier-3 Lorekeep-Reason eval.
285
+ **Phase 2 (planned)** — streamable-HTTP team server, OIDC/SSO, embeddings/hybrid search, scheduled nightly lint/suggest in daemon, schema evolve, HotpotQA/CronQuestions/LongMemEval benchmark datasets and the bespoke Tier-3 Lorekeep-Reason eval.
267
286
 
268
287
  ## Documentation
269
288
 
@@ -41,9 +41,18 @@ Evaluates the **compiler**, not the agent. `lorekeep eval` / `lorekeep check`.
41
41
 
42
42
  Evaluates whether the **query path** returns correct facts.
43
43
 
44
- - Multi-hop QA: HotpotQA / 2WikiMultihopQA / MuSiQue — agent answers using Lorekeep tools; EM/F1.
45
- - Temporal QA: CronQuestions / TimeQuestions + the temporal subset of Atlas — measures `at_time` / `history` / `changes`. This is the industry weak spot (specialized memory systems drop to ~20% on temporal reasoning per Atlas) and Lorekeep's core bet.
46
- - Memory parity: LongMemEval / LoCoMo — sanity that graph retrieval is no worse than vector memory on long-horizon recall. **Not optimized as a target.**
44
+ - **LoCoMo** (shipped): 10 very long-term conversations (300 turns, 35 sessions).
45
+ Converts conversations → `raw/*.md` → compile → graph → programmatic retrieval QA.
46
+ 5 categories: single-hop, temporal, multi-hop, descriptive, adversarial.
47
+ Token-level F1 scoring (HotpotQA-style). Adversarial scored on abstention
48
+ (system should NOT find supporting evidence for plausible-but-wrong answer).
49
+ Run: `lorekeep eval-locomo --data locomo10.json --compile`.
50
+ - HotpotQA / 2WikiMultihopQA / MuSiQue (planned): multi-hop QA with agent-LLM loop.
51
+ - CronQuestions / TimeQuestions (planned): temporal KG QA — measures `at_time` /
52
+ `history` / `changes`. Industry weak spot (specialized memory systems drop to
53
+ ~20% on temporal reasoning per Atlas). Lorekeep's core bet.
54
+ - LongMemEval (planned): 5 abilities — extraction, multi-session reasoning,
55
+ temporal reasoning, knowledge updates, abstention.
47
56
 
48
57
  ### Tier 3 — Systematic-thinking reasoning eval (north star)
49
58
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lorekeep"
3
- version = "0.7.0"
3
+ version = "0.8.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.7.0"
3
+ __version__ = "0.8.0"
@@ -149,6 +149,61 @@ def eval_cmd() -> None:
149
149
  typer.echo(json.dumps(report, indent=2, sort_keys=True))
150
150
 
151
151
 
152
+ @app.command(name="eval-locomo")
153
+ def eval_locomo_cmd(
154
+ data: str = typer.Option("", "--data", help="Path to locomo10.json"),
155
+ compile_first: bool = typer.Option(
156
+ False, "--compile",
157
+ help="Convert JSON to raw/ + compile before running eval",
158
+ ),
159
+ ) -> None:
160
+ """Run Tier-2 LoCoMo retrieval eval."""
161
+ from lorekeep.eval.locomo import convert_locomo, locomo_report
162
+
163
+ p = resolve_paths()
164
+ data_path = Path(data) if data else Path(
165
+ os.environ.get("LOREKEEP_LOCOMO", "locomo10.json")
166
+ )
167
+
168
+ if compile_first:
169
+ if not data_path.exists():
170
+ typer.echo(f"eval-locomo: data file not found: {data_path}")
171
+ raise typer.Exit(code=1)
172
+ count = convert_locomo(data_path, p["raw"] / "locomo")
173
+ typer.echo(f"eval-locomo: converted {count} session files to {p['raw'] / 'locomo'}")
174
+ schema = load_schema(p["schema"])
175
+ config = load_config(p["config"])
176
+ provider = _make_provider(config)
177
+ manifest = compile_graph(
178
+ raw_root=p["raw"], out_dir=p["out"], schema=schema,
179
+ provider=provider, cache_path=p["cache"],
180
+ chunk_lines=config.compile.chunk_lines,
181
+ )
182
+ typer.echo(f"eval-locomo: compiled {manifest.node_count} nodes, {manifest.edge_count} edges")
183
+
184
+ raw_ns = os.environ.get("LOREKEEP_NS")
185
+ allowed = [x.strip() for x in raw_ns.split(",")] if raw_ns else ["locomo"]
186
+ report = locomo_report(p["out"], data_path, allowed, raw_dir=p["raw"])
187
+ if "error" in report:
188
+ typer.echo(f"eval-locomo: {report['error']}")
189
+ raise typer.Exit(code=1)
190
+
191
+ results_path = Path(os.environ.get(
192
+ "LOREKEEP_EVAL_RESULTS", ".lorekeep/eval/locomo-results.json"
193
+ ))
194
+ results_path.parent.mkdir(parents=True, exist_ok=True)
195
+ results_path.write_text(json.dumps(report, indent=2, sort_keys=True))
196
+
197
+ s = report["summary"]
198
+ typer.echo(f"\nLoCoMo Tier-2 Eval ({s['total_questions']} questions)")
199
+ typer.echo(f"Overall F1: {s['overall_f1']}")
200
+ typer.echo("")
201
+ typer.echo(f"{'Category':<20} {'Count':>6} {'F1':>8}")
202
+ typer.echo("-" * 36)
203
+ for cat, stats in s["per_category"].items():
204
+ typer.echo(f"{cat:<20} {stats['count']:>6} {stats['f1']:>8.4f}")
205
+
206
+
152
207
  @app.command()
153
208
  def check() -> None:
154
209
  """Validate the compiled graph: loads, no dangling edges."""
@@ -119,10 +119,12 @@ class ExtractionCache:
119
119
  if self.path.exists():
120
120
  self._data = json.loads(self.path.read_text(encoding="utf-8"))
121
121
 
122
- def key(self, chunk: DocChunk, schema_version: int) -> str:
122
+ def key(self, chunk: DocChunk, schema_version: int, model: str = "") -> str:
123
123
  h = hashlib.sha256()
124
124
  h.update(str(schema_version).encode("utf-8"))
125
125
  h.update(b"\n")
126
+ h.update(model.encode("utf-8"))
127
+ h.update(b"\n")
126
128
  h.update(chunk.hash.encode("utf-8"))
127
129
  return h.hexdigest()
128
130
 
@@ -142,7 +144,8 @@ class ExtractionCache:
142
144
  def extract_chunk(
143
145
  chunk: DocChunk, schema: Schema, provider: LLMProvider, cache: ExtractionCache,
144
146
  ) -> tuple[list[Node], list[Edge], dict[str, list[str]]]:
145
- key = cache.key(chunk, schema.version)
147
+ model = getattr(provider, "model", "")
148
+ key = cache.key(chunk, schema.version, model)
146
149
  raw = cache.get(key)
147
150
  if raw is None:
148
151
  raw = provider.extract_json(SYSTEM_PROMPT, build_prompt(chunk, schema))
@@ -0,0 +1,360 @@
1
+ """LoCoMo Tier-2 evaluation: convert, compile, retrieve, score.
2
+
3
+ Pipeline:
4
+ 1. Converter: LoCoMo JSON → raw/<conv_id>/*.md (one file per session)
5
+ 2. Compile: markdown → facts.jsonl (standard lorekeep pipeline)
6
+ 3. Runner: answer QA questions via graph retrieval (programmatic, no agent LLM)
7
+ 4. Scorer: token-level F1 (HotpotQA-style) per category + overall
8
+
9
+ QA categories (LoCoMo):
10
+ 1 = single-hop descriptive ("What is X?")
11
+ 2 = temporal ("When did X happen?")
12
+ 3 = multi-hop ("Who did X's sister work with?")
13
+ 4 = single-hop ("What did X do?")
14
+ 5 = adversarial (unanswerable / distractor)
15
+
16
+ Scoring:
17
+ cat 1-4: F1 = token overlap between gold answer and retrieved fact text
18
+ cat 5: score 1.0 if search returns 0 results (correct abstention), else 0.0
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ from collections import Counter
25
+ from pathlib import Path
26
+
27
+ from lorekeep.models import Edge, Node
28
+ from lorekeep.perm.ns import ScopedGraph
29
+ from lorekeep.store.graph import GraphStore
30
+
31
+
32
+ # ── Converter ──────────────────────────────────────────────────────────────
33
+
34
+
35
+ def convert_locomo(json_path: Path, raw_dir: Path) -> int:
36
+ """Convert LoCoMo JSON to markdown files under raw_dir.
37
+
38
+ Each conversation session becomes one .md file:
39
+ raw_dir/<conv_id>/session-<N>.md
40
+
41
+ Returns number of files written.
42
+ """
43
+ data = json.loads(Path(json_path).read_text(encoding="utf-8"))
44
+ count = 0
45
+ for conv in data:
46
+ conv_id = conv["sample_id"]
47
+ c = conv["conversation"]
48
+ for n in range(1, 36):
49
+ session_key = f"session_{n}"
50
+ date_key = f"session_{n}_date_time"
51
+ if session_key not in c:
52
+ continue
53
+ date = c.get(date_key, "")
54
+ utterances = c[session_key]
55
+ if not utterances:
56
+ continue
57
+ lines = [
58
+ f"# {conv_id} Session {n}",
59
+ f"Date: {date}",
60
+ f"Speakers: {c.get('speaker_a', 'A')}, {c.get('speaker_b', 'B')}",
61
+ "",
62
+ ]
63
+ for utt in utterances:
64
+ lines.append(f"**{utt['speaker']}** ({utt['dia_id']}): {utt['text']}")
65
+ path = raw_dir / conv_id / f"session-{n}.md"
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
68
+ count += 1
69
+ return count
70
+
71
+
72
+ def extract_questions(json_path: Path) -> list[dict]:
73
+ """Extract all QA items from LoCoMo JSON, flattened with conv_id."""
74
+ data = json.loads(Path(json_path).read_text(encoding="utf-8"))
75
+ questions: list[dict] = []
76
+ for conv in data:
77
+ conv_id = conv["sample_id"]
78
+ for qa in conv.get("qa", []):
79
+ cat = qa.get("category", 0)
80
+ if cat == 5:
81
+ gold = str(qa.get("adversarial_answer", qa.get("answer", "")))
82
+ else:
83
+ gold = str(qa.get("answer", ""))
84
+ questions.append({
85
+ "conv_id": conv_id,
86
+ "question": qa["question"],
87
+ "gold": gold,
88
+ "category": cat,
89
+ "evidence": qa.get("evidence", []),
90
+ "adversarial": cat == 5,
91
+ })
92
+ return questions
93
+
94
+
95
+ # ── Scorer ─────────────────────────────────────────────────────────────────
96
+
97
+
98
+ def _normalize(text: str) -> list[str]:
99
+ """Lowercase, strip articles/punctuation, tokenize (HotpotQA-style)."""
100
+ text = text.lower()
101
+ text = re.sub(r"[^\w\s]", " ", text)
102
+ tokens = text.split()
103
+ articles = {"a", "an", "the", "is", "are", "was", "were", "to", "of", "in", "on", "at"}
104
+ return [t for t in tokens if t not in articles]
105
+
106
+
107
+ def token_f1(gold: str, prediction: str) -> float:
108
+ """Token-level F1 between gold and prediction strings."""
109
+ g = _normalize(gold)
110
+ p = _normalize(prediction)
111
+ if not g and not p:
112
+ return 1.0
113
+ if not g or not p:
114
+ return 0.0
115
+ common = Counter(g) & Counter(p)
116
+ num_same = sum(common.values())
117
+ if num_same == 0:
118
+ return 0.0
119
+ precision = num_same / len(p)
120
+ recall = num_same / len(g)
121
+ return 2 * precision * recall / (precision + recall)
122
+
123
+
124
+ def token_recall(gold: str, prediction: str) -> float:
125
+ """What fraction of gold answer tokens appear in prediction?
126
+
127
+ Better than F1 for retrieval eval: doesn't penalize for retrieving
128
+ extra context (low precision). Measures "can the answer be found?"
129
+ """
130
+ g = _normalize(gold)
131
+ p = _normalize(prediction)
132
+ if not g:
133
+ return 1.0
134
+ if not p:
135
+ return 0.0
136
+ common = Counter(g) & Counter(p)
137
+ num_same = sum(common.values())
138
+ return num_same / len(g)
139
+
140
+
141
+ # ── Runner ─────────────────────────────────────────────────────────────────
142
+
143
+
144
+ def _node_text(node: Node) -> str:
145
+ parts = [node.id, node.type]
146
+ for v in node.props.values():
147
+ parts.append(str(v))
148
+ if node.valid_from:
149
+ parts.append(node.valid_from.isoformat())
150
+ if node.valid_to:
151
+ parts.append(node.valid_to.isoformat())
152
+ return " ".join(parts)
153
+
154
+
155
+ def _edge_text(edge: Edge, store: GraphStore) -> str:
156
+ from_node = store.get_node(edge.from_)
157
+ to_node = store.get_node(edge.to)
158
+ parts = [edge.type, edge.from_, edge.to]
159
+ if from_node:
160
+ parts.append(_node_text(from_node))
161
+ if to_node:
162
+ parts.append(_node_text(to_node))
163
+ for v in edge.props.values():
164
+ parts.append(str(v))
165
+ if edge.valid_from:
166
+ parts.append(edge.valid_from.isoformat())
167
+ if edge.valid_to:
168
+ parts.append(edge.valid_to.isoformat())
169
+ return " ".join(parts)
170
+
171
+
172
+ def _load_src_text(src_refs: tuple[str, ...], raw_dir: Path | None) -> str:
173
+ """Read full source markdown files referenced by src (path:line format)."""
174
+ if not raw_dir or not src_refs:
175
+ return ""
176
+ seen_files: set[str] = set()
177
+ parts: list[str] = []
178
+ for ref in src_refs:
179
+ path_str = ref.rpartition(":")[0]
180
+ if path_str in seen_files:
181
+ continue
182
+ path = raw_dir / path_str
183
+ if not path.exists():
184
+ continue
185
+ seen_files.add(path_str)
186
+ try:
187
+ parts.append(path.read_text(encoding="utf-8"))
188
+ except Exception:
189
+ continue
190
+ if len(seen_files) >= 5:
191
+ break
192
+ return " ".join(parts)
193
+
194
+
195
+ def _search_raw_text(keywords: list[str], raw_dir: Path | None, limit: int = 3) -> str:
196
+ """Direct keyword search over raw markdown files (graph-guided fallback)."""
197
+ if not raw_dir or not keywords:
198
+ return ""
199
+ parts: list[str] = []
200
+ count = 0
201
+ for md_file in sorted(raw_dir.rglob("*.md")):
202
+ try:
203
+ text = md_file.read_text(encoding="utf-8")
204
+ except Exception:
205
+ continue
206
+ lower = text.lower()
207
+ if any(kw.lower() in lower for kw in keywords):
208
+ parts.append(text)
209
+ count += 1
210
+ if count >= limit:
211
+ break
212
+ return " ".join(parts)
213
+
214
+
215
+ def _extract_keywords(question: str) -> list[str]:
216
+ """Extract proper nouns + key content words (one search per keyword)."""
217
+ tokens = question.replace("?", "").replace(".", "").replace("!", "").split()
218
+ common_caps = {
219
+ "What", "When", "Where", "Who", "Why", "How", "Did", "Do", "Does",
220
+ "Was", "Were", "Is", "Are", "The", "A", "An", "I", "My", "Me",
221
+ "Would", "Could", "Will", "Have", "Has", "Had", "They", "Them",
222
+ "Their", "Her", "His", "Its", "This", "That", "These", "Those",
223
+ "Which",
224
+ }
225
+ keywords = [t for t in tokens if t and t[0].isupper() and t not in common_caps]
226
+ stop = {"what", "when", "where", "who", "why", "how", "did", "do", "does",
227
+ "was", "were", "is", "are", "the", "a", "an", "to", "of", "in",
228
+ "on", "at", "for", "from", "about", "after", "before", "during",
229
+ "and", "or", "not", "no", "yes", "can", "could", "would", "will",
230
+ "have", "has", "had", "been", "being", "that", "this", "these",
231
+ "those", "it", "they", "them", "their", "her", "his", "its",
232
+ "ago", "long", "much", "many", "old", "new", "first", "last"}
233
+ content_words = [t.lower() for t in tokens
234
+ if t.lower() not in stop and len(t) > 2
235
+ and t not in common_caps and not t[0].isupper()]
236
+ keywords.extend(content_words[:3])
237
+ return keywords[:8]
238
+
239
+
240
+ def answer_question(
241
+ scoped: ScopedGraph,
242
+ store: GraphStore,
243
+ question: dict,
244
+ raw_dir: Path | None = None,
245
+ ) -> dict:
246
+ """Retrieve facts for a question and score against gold answer.
247
+
248
+ Uses graph-guided retrieval: search → get_node → neighbors(depth=1-2).
249
+ Enriches fact text with source markdown from ``src`` references.
250
+ """
251
+ cat = question["category"]
252
+ keywords = _extract_keywords(question["question"])
253
+ all_node_ids: set[str] = set()
254
+ for kw in keywords:
255
+ ids = scoped.search(kw, limit=5)
256
+ all_node_ids.update(ids)
257
+
258
+ depth = 2 if cat == 3 else 1
259
+ fact_text_parts: list[str] = []
260
+ seen_src: set[str] = set()
261
+
262
+ for nid in sorted(all_node_ids):
263
+ node = scoped.get_node(nid)
264
+ if not node:
265
+ continue
266
+ fact_text_parts.append(_node_text(node))
267
+ if not question["adversarial"]:
268
+ for ref in node.src:
269
+ seen_src.add(ref)
270
+
271
+ res = scoped.neighbors(nid, depth=depth)
272
+ for n in res["nodes"]:
273
+ fact_text_parts.append(_node_text(n))
274
+ if not question["adversarial"]:
275
+ for ref in n.src:
276
+ seen_src.add(ref)
277
+ for e in res["edges"]:
278
+ fact_text_parts.append(_edge_text(e, store))
279
+ if not question["adversarial"]:
280
+ for ref in e.src:
281
+ seen_src.add(ref)
282
+
283
+ if not question["adversarial"]:
284
+ src_text = _load_src_text(tuple(seen_src), raw_dir)
285
+ if src_text:
286
+ fact_text_parts.append(src_text)
287
+ raw_hits = _search_raw_text(keywords, raw_dir)
288
+ if raw_hits:
289
+ fact_text_parts.append(raw_hits)
290
+
291
+ fact_text = " ".join(fact_text_parts)
292
+
293
+ if question["adversarial"]:
294
+ score = 1.0 - token_recall(question["gold"], fact_text)
295
+ else:
296
+ score = token_recall(question["gold"], fact_text)
297
+
298
+ return {
299
+ "question": question["question"],
300
+ "gold": question["gold"],
301
+ "category": question["category"],
302
+ "f1": round(score, 4),
303
+ "matched_nodes": sorted(all_node_ids),
304
+ "adversarial": question["adversarial"],
305
+ }
306
+
307
+
308
+ # ── Full eval ──────────────────────────────────────────────────────────────
309
+
310
+
311
+ CATEGORY_NAMES = {
312
+ 1: "single-hop",
313
+ 2: "temporal",
314
+ 3: "multi-hop",
315
+ 4: "descriptive",
316
+ 5: "adversarial",
317
+ }
318
+
319
+
320
+ def locomo_report(
321
+ graph_dir: Path, json_path: Path, allowed_ns: list[str],
322
+ raw_dir: Path | None = None,
323
+ ) -> dict:
324
+ """Run LoCoMo QA eval against a compiled graph.
325
+
326
+ If ``raw_dir`` is given, source markdown lines are included in the
327
+ fact text for each retrieved node/edge (graph-guided retrieval).
328
+
329
+ Returns per-category F1 + overall metrics.
330
+ """
331
+ facts_path = Path(graph_dir) / "facts.jsonl"
332
+ if not facts_path.exists():
333
+ return {"error": f"facts.jsonl not found at {facts_path}"}
334
+
335
+ store = GraphStore.from_jsonl(facts_path)
336
+ scoped = ScopedGraph(store, allowed_ns)
337
+ questions = extract_questions(json_path)
338
+
339
+ results = [answer_question(scoped, store, q, raw_dir=raw_dir) for q in questions]
340
+
341
+ per_cat: dict[str, list[float]] = {}
342
+ for r in results:
343
+ cat = CATEGORY_NAMES.get(r["category"], f"cat-{r['category']}")
344
+ per_cat.setdefault(cat, []).append(r["f1"])
345
+
346
+ summary = {
347
+ "total_questions": len(results),
348
+ "overall_f1": round(
349
+ sum(r["f1"] for r in results) / len(results), 4
350
+ ) if results else 0.0,
351
+ "per_category": {
352
+ cat: {
353
+ "count": len(scores),
354
+ "f1": round(sum(scores) / len(scores), 4),
355
+ }
356
+ for cat, scores in sorted(per_cat.items())
357
+ },
358
+ "graph_stats": scoped.stats(),
359
+ }
360
+ return {"summary": summary, "results": results}