lorekeep 0.8.4__tar.gz → 0.9.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 (139) hide show
  1. {lorekeep-0.8.4 → lorekeep-0.9.0}/.gitignore +2 -0
  2. lorekeep-0.9.0/.release-please-manifest.json +3 -0
  3. {lorekeep-0.8.4 → lorekeep-0.9.0}/CHANGELOG.md +7 -0
  4. {lorekeep-0.8.4 → lorekeep-0.9.0}/PKG-INFO +1 -1
  5. {lorekeep-0.8.4 → lorekeep-0.9.0}/pyproject.toml +1 -1
  6. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/__init__.py +1 -1
  7. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/backup.py +2 -0
  8. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/cli.py +109 -21
  9. lorekeep-0.9.0/src/lorekeep/providers.py +128 -0
  10. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_backup.py +3 -0
  11. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_init_cli.py +36 -12
  12. lorekeep-0.9.0/tests/test_providers.py +159 -0
  13. {lorekeep-0.8.4 → lorekeep-0.9.0}/uv.lock +4 -4
  14. lorekeep-0.8.4/.release-please-manifest.json +0 -3
  15. lorekeep-0.8.4/tests/test_providers.py +0 -22
  16. {lorekeep-0.8.4 → lorekeep-0.9.0}/.commitlintrc.json +0 -0
  17. {lorekeep-0.8.4 → lorekeep-0.9.0}/.github/workflows/auto-merge-release-please.yml +0 -0
  18. {lorekeep-0.8.4 → lorekeep-0.9.0}/.github/workflows/ci.yml +0 -0
  19. {lorekeep-0.8.4 → lorekeep-0.9.0}/.github/workflows/lint-commits.yml +0 -0
  20. {lorekeep-0.8.4 → lorekeep-0.9.0}/.github/workflows/release-please.yml +0 -0
  21. {lorekeep-0.8.4 → lorekeep-0.9.0}/.lorekeep/config.yaml.example +0 -0
  22. {lorekeep-0.8.4 → lorekeep-0.9.0}/.lorekeep/schema.json +0 -0
  23. {lorekeep-0.8.4 → lorekeep-0.9.0}/.pre-commit-config.yaml +0 -0
  24. {lorekeep-0.8.4 → lorekeep-0.9.0}/AGENTS.md +0 -0
  25. {lorekeep-0.8.4 → lorekeep-0.9.0}/CLAUDE.md +0 -0
  26. {lorekeep-0.8.4 → lorekeep-0.9.0}/LICENSE +0 -0
  27. {lorekeep-0.8.4 → lorekeep-0.9.0}/README.md +0 -0
  28. {lorekeep-0.8.4 → lorekeep-0.9.0}/SECURITY.md +0 -0
  29. {lorekeep-0.8.4 → lorekeep-0.9.0}/cover.jpeg +0 -0
  30. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/README.md +0 -0
  31. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/architecture/agent.md +0 -0
  32. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/architecture/data-model.md +0 -0
  33. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/architecture/evaluation.md +0 -0
  34. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/architecture/journal.md +0 -0
  35. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/architecture/overview.md +0 -0
  36. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/architecture/permission.md +0 -0
  37. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/architecture/pipeline.md +0 -0
  38. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/architecture/serve-mcp.md +0 -0
  39. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/architecture/temporal.md +0 -0
  40. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/guides/backup.md +0 -0
  41. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/guides/compile.md +0 -0
  42. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/guides/data-home.md +0 -0
  43. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/guides/getting-started.md +0 -0
  44. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/guides/import.md +0 -0
  45. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/guides/serve.md +0 -0
  46. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/guides/wiki.md +0 -0
  47. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/superpowers/plans/2026-06-26-lorekeep-dotdir-backup.md +0 -0
  48. {lorekeep-0.8.4 → lorekeep-0.9.0}/docs/superpowers/specs/2026-06-26-lorekeep-dotdir-layout-design.md +0 -0
  49. {lorekeep-0.8.4 → lorekeep-0.9.0}/release-please-config.json +0 -0
  50. {lorekeep-0.8.4 → lorekeep-0.9.0}/scripts/check-conventional-commit.py +0 -0
  51. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/agent.py +0 -0
  52. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/compile/__init__.py +0 -0
  53. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/compile/extract.py +0 -0
  54. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/compile/ingest.py +0 -0
  55. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/compile/providers.py +0 -0
  56. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/compile/resolve.py +0 -0
  57. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/compile/writer.py +0 -0
  58. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/config.py +0 -0
  59. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/defaults.py +0 -0
  60. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/eval/__init__.py +0 -0
  61. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/eval/construction.py +0 -0
  62. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/eval/gold.py +0 -0
  63. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/eval/locomo.py +0 -0
  64. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/eval/retrieval.py +0 -0
  65. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/facts_io.py +0 -0
  66. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/importer/__init__.py +0 -0
  67. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/importer/claude.py +0 -0
  68. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/importer/codex.py +0 -0
  69. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/importer/cursor.py +0 -0
  70. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/importer/opencode.py +0 -0
  71. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/integrations/__init__.py +0 -0
  72. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/integrations/claude_code.py +0 -0
  73. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/integrations/codex.py +0 -0
  74. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/integrations/common.py +0 -0
  75. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/integrations/cursor.py +0 -0
  76. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/integrations/detect.py +0 -0
  77. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/integrations/opencode.py +0 -0
  78. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/journal.py +0 -0
  79. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/mcp_server.py +0 -0
  80. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/models.py +0 -0
  81. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/paths.py +0 -0
  82. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/perm/__init__.py +0 -0
  83. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/perm/ns.py +0 -0
  84. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/pipeline.py +0 -0
  85. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/schema_io.py +0 -0
  86. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/store/__init__.py +0 -0
  87. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/store/fts.py +0 -0
  88. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/store/graph.py +0 -0
  89. {lorekeep-0.8.4 → lorekeep-0.9.0}/src/lorekeep/wiki.py +0 -0
  90. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/conftest.py +0 -0
  91. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/fixtures/claude-session/memory/MEMORY.md +0 -0
  92. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/fixtures/claude-session/memory/lorekeep-design.md +0 -0
  93. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/fixtures/claude-session/memory/lorekeep-project-state.md +0 -0
  94. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/fixtures/claude-session/test.jsonl +0 -0
  95. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/fixtures/gold/payments.facts.jsonl +0 -0
  96. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/fixtures/raw/backend/payments.md +0 -0
  97. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/fixtures/retrieval/questions.json +0 -0
  98. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/fixtures/schema.json +0 -0
  99. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_agent_cli.py +0 -0
  100. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_agent_detect.py +0 -0
  101. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_backup_cli.py +0 -0
  102. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_compile_cli.py +0 -0
  103. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_config.py +0 -0
  104. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_construction_eval.py +0 -0
  105. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_defaults.py +0 -0
  106. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_determinism.py +0 -0
  107. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_doctor_cli.py +0 -0
  108. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_eval_cli.py +0 -0
  109. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_extract.py +0 -0
  110. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_fts.py +0 -0
  111. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_graph_store.py +0 -0
  112. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_import_claude.py +0 -0
  113. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_import_codex.py +0 -0
  114. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_import_cursor.py +0 -0
  115. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_import_opencode.py +0 -0
  116. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_ingest.py +0 -0
  117. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_init_chain.py +0 -0
  118. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_integrations.py +0 -0
  119. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_locomo_eval.py +0 -0
  120. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_mcp_add_cli.py +0 -0
  121. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_mcp_reload.py +0 -0
  122. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_mcp_server.py +0 -0
  123. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_mcp_write_tools.py +0 -0
  124. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_merge_journals.py +0 -0
  125. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_models.py +0 -0
  126. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_paths.py +0 -0
  127. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_perm.py +0 -0
  128. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_pipeline.py +0 -0
  129. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_resolve.py +0 -0
  130. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_retrieval_eval.py +0 -0
  131. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_schema_io.py +0 -0
  132. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_scope_awareness.py +0 -0
  133. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_serve_cli.py +0 -0
  134. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_session_hook.py +0 -0
  135. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_smoke.py +0 -0
  136. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_watch_e2e.py +0 -0
  137. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_watch_sessions.py +0 -0
  138. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_wiki.py +0 -0
  139. {lorekeep-0.8.4 → lorekeep-0.9.0}/tests/test_writer.py +0 -0
@@ -6,6 +6,8 @@
6
6
  *.sqlite-*
7
7
 
8
8
  .mcp.json
9
+ opencode.json
10
+ .opencode/
9
11
 
10
12
  # Python
11
13
  __pycache__/
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.9.0"
3
+ }
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.0](https://github.com/manhhailua/lorekeep/compare/v0.8.4...v0.9.0) (2026-07-01)
4
+
5
+
6
+ ### Features
7
+
8
+ * interactive provider/model picker with litellm catalog ([#89](https://github.com/manhhailua/lorekeep/issues/89)) ([ff915f5](https://github.com/manhhailua/lorekeep/commit/ff915f590c7de72f23e8ce9e8f82453a0a69763d))
9
+
3
10
  ## [0.8.4](https://github.com/manhhailua/lorekeep/compare/v0.8.3...v0.8.4) (2026-06-30)
4
11
 
5
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lorekeep
3
- Version: 0.8.4
3
+ Version: 0.9.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.8.4"
3
+ version = "0.9.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.8.4"
3
+ __version__ = "0.9.0"
@@ -14,8 +14,10 @@ BACKUP_GITIGNORE = """\
14
14
  config.yaml
15
15
  graph/facts.jsonl
16
16
  graph/manifest.json
17
+ wiki/
17
18
  cache.json
18
19
  pending/
20
+ fts.sqlite
19
21
  """
20
22
 
21
23
 
@@ -498,21 +498,93 @@ def _interactive_init(p: dict) -> tuple[str, str, str]:
498
498
  so the caller can write the first file ``raw/<ns>/about.md``.
499
499
  """
500
500
  import yaml
501
+ from lorekeep.providers import (
502
+ list_models, search_providers,
503
+ format_cost, is_dynamic, POPULAR,
504
+ )
501
505
 
502
506
  typer.echo("\n=== Lorekeep setup ===\n")
503
507
 
504
- provider_menu = "Choose an LLM provider:\n" + "\n".join(
505
- f" {k}. {v['label']}" for k, v in PROVIDER_PRESETS.items()
506
- )
507
- choice = typer.prompt(provider_menu, default="1")
508
- preset = PROVIDER_PRESETS.get(choice, PROVIDER_PRESETS["1"])
509
- typer.echo(f" → {preset['label']}\n")
508
+ # ── Provider selection ─────────────────────────────────────────────
509
+ typer.echo("Popular providers:")
510
+ for i, prov in enumerate(POPULAR, 1):
511
+ typer.echo(f" {i}. {prov}")
512
+ typer.echo(f" {len(POPULAR) + 1}. [Search all providers]")
513
+ typer.echo(f" {len(POPULAR) + 2}. [Skip — configure later]")
514
+
515
+ choice = typer.prompt("\nChoice", default="1")
516
+
517
+ idx = int(choice) if choice.isdigit() else 0
518
+ if idx == len(POPULAR) + 2 or choice.lower() == "skip":
519
+ typer.echo(" → Skipped (edit config.yaml to add a provider later)\n")
520
+ ns = typer.prompt("Default namespace", default="private")
521
+ name = typer.prompt("Your name", default="")
522
+ bio = typer.prompt("Bio (one-line intro)", default="")
523
+ _write_config(p, backend="openai", model="gpt-4o-mini",
524
+ api_base=None, api_key_env=None, api_key=None, ns=ns)
525
+ return ns, name, bio
526
+
527
+ if idx == len(POPULAR) + 1 or choice.lower() == "search":
528
+ query = typer.prompt("Type provider name to search", default="")
529
+ from lorekeep.providers import list_providers
530
+ all_providers = list_providers()
531
+ results = search_providers(query, all_providers)
532
+ if not results:
533
+ typer.echo(" → No matches. Using default (openai).")
534
+ provider_name = "openai"
535
+ else:
536
+ typer.echo("")
537
+ for i, (prov, count) in enumerate(results[:20], 1):
538
+ typer.echo(f" {i}. {prov} ({count} models)")
539
+ sub = typer.prompt("Choice", default="1")
540
+ sub_idx = int(sub) if sub.isdigit() else 1
541
+ provider_name = results[min(sub_idx - 1, len(results) - 1)][0]
542
+ elif 1 <= idx <= len(POPULAR):
543
+ provider_name = POPULAR[idx - 1]
544
+ else:
545
+ provider_name = "openai"
510
546
 
511
- model = typer.prompt("Model (litellm string)", default=preset["model"])
547
+ typer.echo(f" → {provider_name}\n")
512
548
 
513
- api_key = None
514
- if preset.get("api_key_env"):
515
- # Inline key, stored in the gitignored config.yaml. Empty input = skip.
549
+ # ── Model selection ────────────────────────────────────────────────
550
+ if is_dynamic(provider_name):
551
+ model = typer.prompt(
552
+ f"Model name (free-text for {provider_name})",
553
+ default="llama3.2" if provider_name == "ollama" else "",
554
+ )
555
+ api_base = typer.prompt(
556
+ "API base URL", default="http://localhost:11434" if provider_name == "ollama" else "",
557
+ ) or None
558
+ else:
559
+ models = list_models(provider_name)
560
+ if models:
561
+ typer.echo("Models (chat, sorted by cost):")
562
+ for i, m in enumerate(models[:20], 1):
563
+ fc = format_cost(m.input_cost)
564
+ fc_out = format_cost(m.output_cost)
565
+ ctx = f"{m.max_input_tokens // 1000}K" if m.max_input_tokens else "?"
566
+ typer.echo(f" {i}. {m.model} in={fc} out={fc_out} ctx={ctx}")
567
+ typer.echo(f" {len(models) + 1}. [Type custom model name]")
568
+ mchoice = typer.prompt("Choice", default="1")
569
+ midx = int(mchoice) if mchoice.isdigit() else 1
570
+ if 1 <= midx <= len(models):
571
+ model = models[midx - 1].model
572
+ elif midx == len(models) + 1:
573
+ model = typer.prompt("Model name (litellm string)", default="")
574
+ else:
575
+ model = models[0].model
576
+ else:
577
+ model = typer.prompt("Model name (litellm string)", default="")
578
+ api_base = None
579
+
580
+ typer.echo(f" → {model}\n")
581
+
582
+ # ── API key (skip for local providers) ─────────────────────────────
583
+ env_var = None
584
+ if is_dynamic(provider_name):
585
+ typer.echo(" → No API key needed for local provider.\n")
586
+ api_key = None
587
+ else:
516
588
  api_key = typer.prompt(
517
589
  "API key (saved into the gitignored config.yaml)",
518
590
  default="",
@@ -521,22 +593,39 @@ def _interactive_init(p: dict) -> tuple[str, str, str]:
521
593
  if api_key:
522
594
  typer.echo(" → key stored in config.yaml\n")
523
595
  else:
524
- typer.echo(" → skipped (add one to config.yaml later)\n")
525
- else:
526
- typer.echo(" → No API key needed for this provider.\n")
596
+ env_var = typer.prompt(
597
+ "API key env var name (or skip)",
598
+ default=f"{provider_name.upper().replace('-', '_')}_API_KEY",
599
+ )
600
+ if env_var.lower() not in ("skip", ""):
601
+ typer.echo(f" → set {env_var} before compiling\n")
602
+ else:
603
+ env_var = None
604
+ typer.echo(" → skipped (add key to config.yaml later)\n")
605
+
606
+ # ── Namespace + profile ────────────────────────────────────────────
607
+ ns = typer.prompt("Default namespace", default="private")
608
+ name = typer.prompt("Your name", default="")
609
+ bio = typer.prompt("Bio (one-line intro)", default="")
610
+
611
+ _write_config(
612
+ p, backend="openai", model=model, api_base=api_base,
613
+ api_key_env=env_var if not api_key else None,
614
+ api_key=api_key, ns=ns,
615
+ )
616
+ return ns, name, bio
527
617
 
528
- ns = typer.prompt("Default namespace", default="me")
529
- name = typer.prompt("Your name (what the agent calls you)", default="")
530
- bio = typer.prompt("Bio (one-line intro about you)", default="")
531
618
 
619
+ def _write_config(p, backend, model, api_base, api_key_env, api_key, ns):
620
+ """Write config.yaml from provider selection."""
621
+ import yaml
532
622
  install_source = "local" if (Path.cwd() / ".lorekeep").exists() else "pypi"
533
-
534
623
  config = {
535
624
  "provider": {
536
- "backend": preset["backend"],
625
+ "backend": backend,
537
626
  "model": model,
538
- "api_base": preset["api_base"],
539
- "api_key_env": None,
627
+ "api_base": api_base,
628
+ "api_key_env": api_key_env,
540
629
  "api_key": api_key,
541
630
  "temperature": 0.0,
542
631
  },
@@ -545,7 +634,6 @@ def _interactive_init(p: dict) -> tuple[str, str, str]:
545
634
  "install_source": install_source,
546
635
  }
547
636
  p["config"].write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
548
- return ns, name, bio
549
637
 
550
638
 
551
639
  def _auto_wire_agents(p: dict, ns: str) -> None:
@@ -0,0 +1,128 @@
1
+ """Provider/model enumeration via litellm for interactive setup.
2
+
3
+ Lists all providers with chat models, lets the user search and pick,
4
+ then lists chat models for the selected provider with cost info.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import sys
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ModelInfo:
15
+ """Display info for a single model."""
16
+ model: str
17
+ provider: str
18
+ mode: str
19
+ input_cost: float # per token
20
+ output_cost: float
21
+ max_input_tokens: int | None
22
+ supports_function_calling: bool
23
+
24
+
25
+ def _suppress_stderr():
26
+ """Redirect stderr to suppress litellm's 'Provider List' warnings."""
27
+ old = os.dup(2)
28
+ devnull = os.open(os.devnull, os.O_WRONLY)
29
+ os.dup2(devnull, 2)
30
+ return old, devnull
31
+
32
+
33
+ def _restore_stderr(old, devnull):
34
+ os.dup2(old, 2)
35
+ os.close(old)
36
+ os.close(devnull)
37
+
38
+
39
+ def list_providers() -> list[tuple[str, int]]:
40
+ """Return (provider_name, chat_model_count) for all providers with chat models.
41
+
42
+ Sorted alphabetically.
43
+ """
44
+ import litellm
45
+
46
+ old, devnull = _suppress_stderr()
47
+ try:
48
+ result: list[tuple[str, int]] = []
49
+ for provider in sorted(litellm.models_by_provider.keys()):
50
+ models = litellm.models_by_provider[provider]
51
+ chat_count = 0
52
+ for m in models:
53
+ try:
54
+ info = litellm.get_model_info(m)
55
+ if info.get("mode") == "chat":
56
+ chat_count += 1
57
+ except Exception:
58
+ pass
59
+ if chat_count > 0:
60
+ result.append((provider, chat_count))
61
+ return result
62
+ finally:
63
+ _restore_stderr(old, devnull)
64
+
65
+
66
+ def list_models(provider: str) -> list[ModelInfo]:
67
+ """Return chat models for a provider, sorted by cost (cheapest first)."""
68
+ import litellm
69
+
70
+ old, devnull = _suppress_stderr()
71
+ try:
72
+ raw = litellm.models_by_provider.get(provider, set())
73
+ models: list[ModelInfo] = []
74
+ for m in sorted(raw):
75
+ try:
76
+ info = litellm.get_model_info(m)
77
+ except Exception:
78
+ continue
79
+ if info.get("mode") != "chat":
80
+ continue
81
+ models.append(ModelInfo(
82
+ model=m,
83
+ provider=provider,
84
+ mode=info.get("mode", "chat"),
85
+ input_cost=info.get("input_cost_per_token", 0) or 0,
86
+ output_cost=info.get("output_cost_per_token", 0) or 0,
87
+ max_input_tokens=info.get("max_input_tokens"),
88
+ supports_function_calling=info.get("supports_function_calling", False),
89
+ ))
90
+ models.sort(key=lambda x: x.input_cost)
91
+ return models
92
+ finally:
93
+ _restore_stderr(old, devnull)
94
+
95
+
96
+ def format_cost(cost_per_token: float) -> str:
97
+ """Format per-token cost as $X/M-tokens."""
98
+ per_million = cost_per_token * 1_000_000
99
+ if per_million == 0:
100
+ return "free"
101
+ if per_million < 1:
102
+ return f"${per_million:.2f}/M"
103
+ return f"${per_million:.0f}/M"
104
+
105
+
106
+ # Providers that allow free-text model names (local runtimes)
107
+ DYNAMIC_PROVIDERS = {"ollama", "vllm", "lm_studio", "openai_compat", "aleph_alpha"}
108
+
109
+
110
+ def is_dynamic(provider: str) -> bool:
111
+ """True for providers where model names are free-text (ollama, vllm, etc.)."""
112
+ return provider in DYNAMIC_PROVIDERS
113
+
114
+
115
+ def search_providers(query: str, providers: list[tuple[str, int]] | None = None) -> list[tuple[str, int]]:
116
+ """Fuzzy search providers by name."""
117
+ if providers is None:
118
+ providers = list_providers()
119
+ q = query.lower()
120
+ return [(p, c) for p, c in providers if q in p.lower()]
121
+
122
+
123
+ # Popular providers shown first in the default list
124
+ POPULAR = [
125
+ "openai", "anthropic", "deepseek", "dashscope", "gemini",
126
+ "groq", "mistral", "xai", "ollama", "together_ai",
127
+ "fireworks_ai", "openrouter", "perplexity", "cohere", "ai21",
128
+ ]
@@ -82,12 +82,15 @@ def test_backup_never_tracks_secret_or_regenerable(tmp_path: Path):
82
82
  (home / "graph").mkdir()
83
83
  (home / "graph" / "facts.jsonl").write_text("{}")
84
84
  (home / "graph" / "manifest.json").write_text("{}")
85
+ (home / "wiki").mkdir()
86
+ (home / "wiki" / "index.md").write_text("# secret in wiki")
85
87
  (home / "cache.json").write_text("{}")
86
88
  backup(home)
87
89
  tracked = _tracked(home)
88
90
  assert "config.yaml" not in tracked
89
91
  assert "graph/facts.jsonl" not in tracked
90
92
  assert "graph/manifest.json" not in tracked
93
+ assert "wiki/index.md" not in tracked
91
94
  assert "cache.json" not in tracked
92
95
 
93
96
 
@@ -43,18 +43,25 @@ def test_init_yes_flag_skips_prompts(tmp_path: Path, monkeypatch):
43
43
 
44
44
 
45
45
  def test_init_interactive(tmp_path: Path, monkeypatch):
46
- """Interactive: DashScope, default model, empty key, ns=myteam, name, bio."""
46
+ """Interactive: deepseek (3), default model, empty key + env var, ns, name, bio."""
47
47
  home = tmp_path / "home"
48
48
  monkeypatch.setenv("LOREKEEP_HOME", str(home))
49
49
  monkeypatch.setattr("lorekeep.cli._is_interactive", lambda: True)
50
50
 
51
- # provider=3 (DashScope), model=default, key=empty, ns=myteam, name=Alice, bio=...
52
- result = runner.invoke(app, ["init"], input="3\n\n\nmyteam\nAlice\nBuilds backend infra\n")
51
+ # Mock list_models to avoid slow litellm calls
52
+ from lorekeep.providers import ModelInfo
53
+ monkeypatch.setattr(
54
+ "lorekeep.providers.list_models",
55
+ lambda p: [ModelInfo("deepseek-chat", p, "chat", 0.28e-6, 0.42e-6, 131000, True)]
56
+ )
57
+
58
+ # provider=3 (deepseek), model=1, key=empty, env=DEEPSEEK_API_KEY, ns=myteam, name, bio
59
+ result = runner.invoke(app, ["init"], input="3\n1\n\n\nmyteam\nAlice\nBuilds backend infra\n")
53
60
  assert result.exit_code == 0, result.stdout
54
61
  cfg = yaml.safe_load((home / "config.yaml").read_text())
55
- assert cfg["provider"]["model"] == "openai/qwen-plus"
62
+ assert cfg["provider"]["model"] == "deepseek-chat"
56
63
  assert cfg["provider"]["api_key"] is None
57
- assert cfg["provider"]["api_key_env"] is None
64
+ assert cfg["provider"]["api_key_env"] == "DEEPSEEK_API_KEY"
58
65
  assert cfg["ns"]["default"] == ["myteam"]
59
66
  about = home / "raw" / "myteam" / "about.md"
60
67
  assert about.exists()
@@ -64,15 +71,22 @@ def test_init_interactive(tmp_path: Path, monkeypatch):
64
71
 
65
72
 
66
73
  def test_init_interactive_stores_inline_key(tmp_path: Path, monkeypatch):
67
- """Interactive: OpenAI preset with a typed key is stored inline in config.yaml."""
74
+ """Interactive: OpenAI (1) with inline key stored in config.yaml."""
68
75
  home = tmp_path / "home"
69
76
  monkeypatch.setenv("LOREKEEP_HOME", str(home))
70
77
  monkeypatch.setattr("lorekeep.cli._is_interactive", lambda: True)
71
78
 
72
- # provider=1 (OpenAI), model=default, key=sk-testKEY, ns=me, name, bio
73
- result = runner.invoke(app, ["init"], input="1\n\nsk-testKEY\nme\nBob\nlocal dev\n")
79
+ from lorekeep.providers import ModelInfo
80
+ monkeypatch.setattr(
81
+ "lorekeep.providers.list_models",
82
+ lambda p: [ModelInfo("gpt-4o-mini", p, "chat", 0.15e-6, 0.6e-6, 128000, True)]
83
+ )
84
+
85
+ # provider=1 (openai), model=1, key=sk-testKEY, ns=me, name, bio
86
+ result = runner.invoke(app, ["init"], input="1\n1\nsk-testKEY\nme\nBob\nlocal dev\n")
74
87
  assert result.exit_code == 0, result.stdout
75
88
  cfg = yaml.safe_load((home / "config.yaml").read_text())
89
+ assert cfg["provider"]["model"] == "gpt-4o-mini"
76
90
  assert cfg["provider"]["api_key"] == "sk-testKEY"
77
91
  assert cfg["provider"]["api_key_env"] is None
78
92
  assert cfg["ns"]["default"] == ["me"]
@@ -82,16 +96,26 @@ def test_init_interactive_stores_inline_key(tmp_path: Path, monkeypatch):
82
96
 
83
97
 
84
98
  def test_init_interactive_ollama_no_key(tmp_path: Path, monkeypatch):
85
- """Ollama preset: no API key prompt."""
99
+ """Ollama: free-text model, api_base prompt, no API key needed."""
86
100
  home = tmp_path / "home"
87
101
  monkeypatch.setenv("LOREKEEP_HOME", str(home))
88
102
  monkeypatch.setattr("lorekeep.cli._is_interactive", lambda: True)
89
103
 
90
- # Ollama (4), default model, ns=myproject, name, bio
91
- result = runner.invoke(app, ["init"], input="4\n\nmyproject\nCJ\ndemo\n")
104
+ # Ollama is now in POPULAR list but is dynamic — free-text model + api_base
105
+ # Find ollama's index in POPULAR list
106
+ from lorekeep.providers import POPULAR
107
+ ollama_idx = POPULAR.index("ollama") + 1 if "ollama" in POPULAR else None
108
+
109
+ if ollama_idx is None:
110
+ # ollama not in POPULAR, use search
111
+ pytest.skip("ollama not in POPULAR list")
112
+
113
+ # provider=ollama_idx, model=llama3.2, api_base=default, ns=myproject, name, bio
114
+ inp = f"{ollama_idx}\nllama3.2\n\nmyproject\nCJ\ndemo\n"
115
+ result = runner.invoke(app, ["init"], input=inp)
92
116
  assert result.exit_code == 0, result.stdout
93
117
  cfg = yaml.safe_load((home / "config.yaml").read_text())
94
- assert cfg["provider"]["model"] == "ollama/llama3.2"
118
+ assert cfg["provider"]["model"] == "llama3.2"
95
119
  assert cfg["provider"]["api_key"] is None
96
120
  assert cfg["ns"]["default"] == ["myproject"]
97
121
 
@@ -0,0 +1,159 @@
1
+ """Tests for provider/model enumeration."""
2
+ from __future__ import annotations
3
+
4
+ from unittest.mock import patch, MagicMock
5
+
6
+ import pytest
7
+
8
+ from lorekeep.providers import (
9
+ ModelInfo,
10
+ format_cost,
11
+ is_dynamic,
12
+ search_providers,
13
+ DYNAMIC_PROVIDERS,
14
+ )
15
+
16
+
17
+ class TestFormatCost:
18
+ def test_free(self):
19
+ assert format_cost(0) == "free"
20
+
21
+ def test_sub_dollar(self):
22
+ assert format_cost(0.00000028) == "$0.28/M"
23
+
24
+ def test_above_dollar(self):
25
+ assert format_cost(0.000003) == "$3/M"
26
+
27
+ def test_high_cost(self):
28
+ assert format_cost(0.000015) == "$15/M"
29
+
30
+
31
+ class TestIsDynamic:
32
+ def test_ollama(self):
33
+ assert is_dynamic("ollama")
34
+
35
+ def test_vllm(self):
36
+ assert is_dynamic("vllm")
37
+
38
+ def test_openai_not_dynamic(self):
39
+ assert not is_dynamic("openai")
40
+
41
+ def test_deepseek_not_dynamic(self):
42
+ assert not is_dynamic("deepseek")
43
+
44
+
45
+ class TestSearchProviders:
46
+ def test_search_deepseek(self):
47
+ providers = [("openai", 150), ("deepseek", 4), ("anthropic", 22)]
48
+ results = search_providers("deep", providers)
49
+ assert results == [("deepseek", 4)]
50
+
51
+ def test_search_no_match(self):
52
+ providers = [("openai", 150), ("anthropic", 22)]
53
+ results = search_providers("nonexistent", providers)
54
+ assert results == []
55
+
56
+ def test_search_partial(self):
57
+ providers = [("openai", 150), ("openrouter", 96), ("anthropic", 22)]
58
+ results = search_providers("open", providers)
59
+ assert len(results) == 2
60
+ assert "openai" in [r[0] for r in results]
61
+ assert "openrouter" in [r[0] for r in results]
62
+
63
+ def test_search_case_insensitive(self):
64
+ providers = [("OpenAI", 150)]
65
+ results = search_providers("open", providers)
66
+ assert len(results) == 1
67
+
68
+
69
+ class TestListProviders:
70
+ """Tests that mock litellm to avoid network/import issues."""
71
+
72
+ @patch("litellm.models_by_provider")
73
+ @patch("litellm.get_model_info")
74
+ def test_returns_providers_with_chat_models(self, mock_info, mock_map):
75
+ from lorekeep.providers import list_providers
76
+
77
+ mock_map.keys.return_value = ["openai", "anthropic", "assemblyai"]
78
+ mock_map.__getitem__ = MagicMock(side_effect=lambda k: {
79
+ "openai": {"gpt-4o"},
80
+ "anthropic": {"claude-3-sonnet"},
81
+ "assemblyai": set(),
82
+ }[k])
83
+
84
+ def fake_info(model):
85
+ if "gpt" in model:
86
+ return {"mode": "chat"}
87
+ if "claude" in model:
88
+ return {"mode": "chat"}
89
+ return {"mode": "audio_transcription"}
90
+
91
+ mock_info.side_effect = fake_info
92
+
93
+ result = list_providers()
94
+ names = [r[0] for r in result]
95
+ assert "openai" in names
96
+ assert "anthropic" in names
97
+ assert "assemblyai" not in names
98
+
99
+ @patch("litellm.models_by_provider")
100
+ @patch("litellm.get_model_info")
101
+ def test_counts_chat_models(self, mock_info, mock_map):
102
+ from lorekeep.providers import list_providers
103
+
104
+ mock_map.keys.return_value = ["openai"]
105
+ mock_map.__getitem__ = MagicMock(return_value={"gpt-4o", "text-embedding-3"})
106
+
107
+ def fake_info(model):
108
+ return {"mode": "chat"} if "gpt" in model else {"mode": "embedding"}
109
+
110
+ mock_info.side_effect = fake_info
111
+
112
+ result = list_providers()
113
+ assert result == [("openai", 1)]
114
+
115
+
116
+ class TestListModels:
117
+ @patch("litellm.models_by_provider", {"openai": {"model-a", "model-b", "model-c"}})
118
+ @patch("litellm.get_model_info")
119
+ def test_returns_chat_models_sorted_by_cost(self, mock_info):
120
+ from lorekeep.providers import list_models
121
+
122
+ models_data = {
123
+ "model-a": {"mode": "chat", "input_cost_per_token": 0.000003, "output_cost_per_token": 0.000015,
124
+ "max_input_tokens": 128000, "supports_function_calling": True},
125
+ "model-b": {"mode": "chat", "input_cost_per_token": 0.0000005, "output_cost_per_token": 0.000001,
126
+ "max_input_tokens": 32000, "supports_function_calling": False},
127
+ "model-c": {"mode": "embedding", "input_cost_per_token": 0.0, "output_cost_per_token": 0.0,
128
+ "max_input_tokens": None, "supports_function_calling": False},
129
+ }
130
+ mock_info.side_effect = lambda m: models_data[m]
131
+
132
+ result = list_models("openai")
133
+ assert len(result) == 2
134
+ assert result[0].model == "model-b" # cheaper first
135
+ assert result[1].model == "model-a"
136
+ assert all(r.mode == "chat" for r in result)
137
+
138
+ @patch("litellm.models_by_provider", {"openai": {"good", "bad"}})
139
+ @patch("litellm.get_model_info")
140
+ def test_skips_unmapped_models(self, mock_info):
141
+ from lorekeep.providers import list_models
142
+
143
+ def fake_info(model):
144
+ if model == "bad":
145
+ raise ValueError("unmapped")
146
+ return {"mode": "chat", "input_cost_per_token": 0, "output_cost_per_token": 0,
147
+ "max_input_tokens": None, "supports_function_calling": False}
148
+
149
+ mock_info.side_effect = fake_info
150
+
151
+ result = list_models("openai")
152
+ assert len(result) == 1
153
+ assert result[0].model == "good"
154
+
155
+ @patch("litellm.models_by_provider", {})
156
+ def test_empty_provider(self):
157
+ from lorekeep.providers import list_models
158
+ result = list_models("nonexistent")
159
+ assert result == []
@@ -1024,7 +1024,7 @@ wheels = [
1024
1024
 
1025
1025
  [[package]]
1026
1026
  name = "lorekeep"
1027
- version = "0.8.3"
1027
+ version = "0.8.4"
1028
1028
  source = { editable = "." }
1029
1029
  dependencies = [
1030
1030
  { name = "litellm" },
@@ -1630,16 +1630,16 @@ wheels = [
1630
1630
 
1631
1631
  [[package]]
1632
1632
  name = "pydantic-settings"
1633
- version = "2.14.1"
1633
+ version = "2.14.2"
1634
1634
  source = { registry = "https://pypi.org/simple" }
1635
1635
  dependencies = [
1636
1636
  { name = "pydantic" },
1637
1637
  { name = "python-dotenv" },
1638
1638
  { name = "typing-inspection" },
1639
1639
  ]
1640
- sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" }
1640
+ sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
1641
1641
  wheels = [
1642
- { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" },
1642
+ { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
1643
1643
  ]
1644
1644
 
1645
1645
  [[package]]
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.8.4"
3
- }
@@ -1,22 +0,0 @@
1
- from lorekeep.compile.providers import LLMProvider, FakeProvider, LiteLLMProvider
2
-
3
-
4
- def test_fake_provider_returns_fixed_output():
5
- p = FakeProvider(responses=['{"nodes":[],"edges":[]}'])
6
- assert p.extract_json("sys", "user") == '{"nodes":[],"edges":[]}'
7
-
8
-
9
- def test_fake_provider_raises_when_empty():
10
- p = FakeProvider(responses=[])
11
- try:
12
- p.extract_json("sys", "user")
13
- assert False, "expected RuntimeError"
14
- except RuntimeError:
15
- pass
16
-
17
-
18
- def test_litellm_provider_holds_config():
19
- p = LiteLLMProvider(model="ollama/llama3", api_base="http://localhost:11434")
20
- assert p.model == "ollama/llama3"
21
- assert p.api_base == "http://localhost:11434"
22
- assert isinstance(p, LLMProvider)
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