lore-solo 0.1.0__py3-none-any.whl

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 (101) hide show
  1. lore/__init__.py +3 -0
  2. lore/__main__.py +43 -0
  3. lore/connect.py +232 -0
  4. lore/core/__init__.py +1 -0
  5. lore/core/budget.py +40 -0
  6. lore/core/codebases.py +101 -0
  7. lore/core/config.py +39 -0
  8. lore/core/context.py +248 -0
  9. lore/core/decisions.py +60 -0
  10. lore/core/graph.py +184 -0
  11. lore/core/idempotency.py +36 -0
  12. lore/core/inbox.py +333 -0
  13. lore/core/okf.py +64 -0
  14. lore/core/onboarding.py +51 -0
  15. lore/core/paths.py +75 -0
  16. lore/core/personas.py +298 -0
  17. lore/core/picker.py +53 -0
  18. lore/core/provider.py +372 -0
  19. lore/core/tests/pack.py +7 -0
  20. lore/core/tests/test_connect.py +67 -0
  21. lore/core/tests/test_context.py +95 -0
  22. lore/core/tests/test_files.py +123 -0
  23. lore/core/tests/test_graph.py +79 -0
  24. lore/core/tests/test_inbox.py +118 -0
  25. lore/core/tests/test_install.py +43 -0
  26. lore/core/tests/test_library_flow.py +130 -0
  27. lore/core/tests/test_mcp.py +101 -0
  28. lore/core/tests/test_picker.py +32 -0
  29. lore/core/tests/test_provider.py +175 -0
  30. lore/core/usage.py +93 -0
  31. lore/core/workspace.py +88 -0
  32. lore/install_cmd.py +101 -0
  33. lore/mcp.py +5 -0
  34. lore/mcp_server.py +121 -0
  35. lore/py.typed +0 -0
  36. lore/setup/__init__.py +1 -0
  37. lore/setup/graph_job.py +96 -0
  38. lore/setup/middleware.py +30 -0
  39. lore/setup/settings.py +21 -0
  40. lore/setup/tests/test_site.py +217 -0
  41. lore/setup/urls.py +40 -0
  42. lore/setup/views.py +715 -0
  43. lore/setup_cmd.py +221 -0
  44. lore/web_src/app/codebase/[id]/page.tsx +154 -0
  45. lore/web_src/app/codebase/page.tsx +91 -0
  46. lore/web_src/app/connect/page.tsx +95 -0
  47. lore/web_src/app/globals.css +129 -0
  48. lore/web_src/app/layout.tsx +36 -0
  49. lore/web_src/app/locked/page.tsx +16 -0
  50. lore/web_src/app/page.tsx +176 -0
  51. lore/web_src/app/personas/[id]/page.tsx +181 -0
  52. lore/web_src/app/personas/page.tsx +121 -0
  53. lore/web_src/app/replay/page.tsx +55 -0
  54. lore/web_src/app/settings/page.tsx +278 -0
  55. lore/web_src/app/skills/[...id]/page.tsx +124 -0
  56. lore/web_src/app/skills/page.tsx +248 -0
  57. lore/web_src/components/Shell.tsx +53 -0
  58. lore/web_src/components/app-sidebar.tsx +100 -0
  59. lore/web_src/components/error-alert.tsx +10 -0
  60. lore/web_src/components/folder-picker.tsx +48 -0
  61. lore/web_src/components/onboarding.tsx +675 -0
  62. lore/web_src/components/site-header.tsx +55 -0
  63. lore/web_src/components/ui/alert.tsx +75 -0
  64. lore/web_src/components/ui/avatar.tsx +108 -0
  65. lore/web_src/components/ui/badge.tsx +51 -0
  66. lore/web_src/components/ui/breadcrumb.tsx +124 -0
  67. lore/web_src/components/ui/button.tsx +57 -0
  68. lore/web_src/components/ui/card.tsx +102 -0
  69. lore/web_src/components/ui/dropdown-menu.tsx +267 -0
  70. lore/web_src/components/ui/field.tsx +238 -0
  71. lore/web_src/components/ui/input.tsx +19 -0
  72. lore/web_src/components/ui/label.tsx +19 -0
  73. lore/web_src/components/ui/scroll-area.tsx +54 -0
  74. lore/web_src/components/ui/select.tsx +200 -0
  75. lore/web_src/components/ui/separator.tsx +24 -0
  76. lore/web_src/components/ui/sheet.tsx +138 -0
  77. lore/web_src/components/ui/sidebar.tsx +723 -0
  78. lore/web_src/components/ui/skeleton.tsx +13 -0
  79. lore/web_src/components/ui/sonner.tsx +49 -0
  80. lore/web_src/components/ui/table.tsx +115 -0
  81. lore/web_src/components/ui/textarea.tsx +17 -0
  82. lore/web_src/components/ui/tooltip.tsx +65 -0
  83. lore/web_src/components.json +25 -0
  84. lore/web_src/hooks/use-mobile.ts +19 -0
  85. lore/web_src/lib/api.ts +62 -0
  86. lore/web_src/lib/providers.ts +20 -0
  87. lore/web_src/lib/routes.ts +11 -0
  88. lore/web_src/lib/utils.ts +1 -0
  89. lore/web_src/middleware.ts +29 -0
  90. lore/web_src/next-env.d.ts +6 -0
  91. lore/web_src/next.config.ts +26 -0
  92. lore/web_src/package-lock.json +5703 -0
  93. lore/web_src/package.json +31 -0
  94. lore/web_src/postcss.config.mjs +1 -0
  95. lore/web_src/tsconfig.json +40 -0
  96. lore_solo-0.1.0.dist-info/METADATA +122 -0
  97. lore_solo-0.1.0.dist-info/RECORD +101 -0
  98. lore_solo-0.1.0.dist-info/WHEEL +5 -0
  99. lore_solo-0.1.0.dist-info/entry_points.txt +2 -0
  100. lore_solo-0.1.0.dist-info/licenses/LICENSE +21 -0
  101. lore_solo-0.1.0.dist-info/top_level.txt +1 -0
lore/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Lore engine: personas, skills, and a codebase map for one developer."""
2
+
3
+ __version__ = "0.1.0"
lore/__main__.py ADDED
@@ -0,0 +1,43 @@
1
+ """`lore-solo install`, `setup`, `connect`, and `mcp`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def main(argv: list[str] | None = None) -> int:
9
+ args = list(sys.argv[1:] if argv is None else argv)
10
+ if not args or args[0] in {"-h", "--help"}:
11
+ print("usage: lore-solo install|setup|connect|mcp", file=sys.stderr)
12
+ return 0
13
+ command, rest = args[0], args[1:]
14
+ if command == "install":
15
+ from lore.install_cmd import run as run_install
16
+
17
+ run_install(rest)
18
+ return 0
19
+ if command == "setup":
20
+ from lore.setup_cmd import run as run_setup
21
+
22
+ run_setup(rest)
23
+ return 0
24
+ if command == "connect":
25
+ from lore.connect import register_agents
26
+
27
+ result = register_agents()
28
+ for path in result["written"]:
29
+ print(path)
30
+ for skipped in result["skipped"]:
31
+ print(f"skipped {skipped}", file=sys.stderr)
32
+ return 0 if result["written"] else 1
33
+ if command == "mcp":
34
+ from lore.mcp import run as run_mcp
35
+
36
+ run_mcp(rest)
37
+ return 0
38
+ print(f"unknown command {command!r}; use install, setup, connect, or mcp", file=sys.stderr)
39
+ return 2
40
+
41
+
42
+ if __name__ == "__main__":
43
+ raise SystemExit(main())
lore/connect.py ADDED
@@ -0,0 +1,232 @@
1
+ """MCP entry for Lore, and the editor configs the connect command writes.
2
+
3
+ The command is a local process. The editor starts it with the open project as
4
+ its working directory.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import platform
12
+ import shutil
13
+ from pathlib import Path
14
+
15
+
16
+ def invocation() -> tuple[str, list[str]]:
17
+ """The program an editor should start, and the arguments that select MCP."""
18
+ explicit = os.environ.get("LORE_COMMAND", "").strip()
19
+ if explicit:
20
+ return explicit, ["mcp"]
21
+ installed = _installed_command()
22
+ if installed is not None:
23
+ return str(installed), ["mcp"]
24
+ for name in ("lore-solo", "lore"):
25
+ found = shutil.which(name)
26
+ if found:
27
+ return found, ["mcp"]
28
+ return "uvx", ["lore-solo", "mcp"]
29
+
30
+
31
+ def lore_command() -> str:
32
+ """The program editors should start."""
33
+ return invocation()[0]
34
+
35
+
36
+ def _installed_command() -> Path | None:
37
+ bin_dir = Path.home() / ".lore" / "bin"
38
+ for name in ("lore-solo", "lore"):
39
+ base = bin_dir / name
40
+ if os.name == "nt":
41
+ windows = base.with_suffix(".exe")
42
+ if windows.is_file():
43
+ return windows
44
+ if base.is_file():
45
+ return base
46
+ return None
47
+
48
+
49
+ def mcp_args() -> list[str]:
50
+ return invocation()[1]
51
+
52
+
53
+ def mcp_entry(
54
+ kind: str,
55
+ command: str | None = None,
56
+ client: str = "",
57
+ args: list[str] | None = None,
58
+ ) -> dict[str, object]:
59
+ chosen, chosen_args = invocation()
60
+ if command is not None:
61
+ chosen = command
62
+ if args is not None:
63
+ chosen_args = args
64
+ elif command is not None:
65
+ chosen_args = ["mcp"]
66
+ entry: dict[str, object] = {"command": chosen, "args": chosen_args}
67
+ if kind == "vscode":
68
+ entry["type"] = "stdio"
69
+ name = client.strip()
70
+ if name:
71
+ entry["env"] = {"LORE_CLIENT": name}
72
+ return entry
73
+
74
+
75
+ def install_command() -> str:
76
+ launcher = os.environ.get("LORE_LAUNCHER", "").strip()
77
+ if launcher:
78
+ return f"{launcher} connect"
79
+ return "uvx lore-solo connect"
80
+
81
+
82
+ def windows_command(command: str) -> str:
83
+ if command.startswith("uvx "):
84
+ return command
85
+ suffix = " connect"
86
+ if command.endswith(suffix):
87
+ script = command[: -len(suffix)]
88
+ if script.endswith(".ps1"):
89
+ return command
90
+ return f"{script}.ps1 connect"
91
+ return "scripts/lore-solo.ps1 connect"
92
+
93
+
94
+ def agent_prompt(command: str | None = None) -> str:
95
+ chosen = command or install_command()
96
+ windows = windows_command(chosen)
97
+ if windows == chosen:
98
+ steps = f"On macOS, Linux, or Windows, run:\n\n{chosen}\n\n"
99
+ else:
100
+ steps = (
101
+ "On macOS or Linux, run this in the shell:\n\n"
102
+ f"{chosen}\n\n"
103
+ "On Windows, run this in PowerShell:\n\n"
104
+ f"{windows}\n\n"
105
+ )
106
+ return (
107
+ "Register Lore with the editors on this computer. "
108
+ f"{steps}"
109
+ "When it finishes, confirm Lore is registered with the editors it found. "
110
+ "A project that already has a .lore directory keeps using that directory."
111
+ )
112
+
113
+
114
+ def host_system(value: str | None = None) -> str:
115
+ raw = (value or os.environ.get("LORE_HOST_OS") or platform.system()).lower()
116
+ if raw.startswith("darwin") or raw == "macos":
117
+ return "darwin"
118
+ if raw.startswith("win"):
119
+ return "windows"
120
+ return "linux"
121
+
122
+
123
+ def _write_json(path: Path, key: str, entry: dict[str, object]) -> None:
124
+ data: dict[str, object] = {}
125
+ if path.is_file():
126
+ raw = path.read_text(encoding="utf-8").strip()
127
+ if raw:
128
+ loaded = json.loads(raw)
129
+ if not isinstance(loaded, dict):
130
+ raise ValueError(f"{path} is not a JSON object.")
131
+ data = loaded
132
+ backup = path.with_name(path.name + ".lore.bak")
133
+ if not backup.exists():
134
+ shutil.copyfile(path, backup)
135
+ servers = data.get(key)
136
+ if not isinstance(servers, dict):
137
+ servers = {}
138
+ servers["lore"] = entry
139
+ data[key] = servers
140
+ path.parent.mkdir(parents=True, exist_ok=True)
141
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
142
+
143
+
144
+ def _strip_toml_section(text: str, heading: str) -> str:
145
+ kept: list[str] = []
146
+ skipping = False
147
+ blanks: list[str] = []
148
+ for line in text.splitlines():
149
+ stripped = line.strip()
150
+ if stripped.startswith("["):
151
+ skipping = stripped == heading or stripped.startswith(heading + " ")
152
+ if skipping:
153
+ continue
154
+ if stripped == "":
155
+ blanks.append(line)
156
+ continue
157
+ kept.extend(blanks)
158
+ blanks = []
159
+ kept.append(line)
160
+ return "\n".join(kept).rstrip()
161
+
162
+
163
+ def _write_codex(path: Path, command: str, client: str = "", args: list[str] | None = None) -> None:
164
+ existing = path.read_text(encoding="utf-8") if path.is_file() else ""
165
+ if path.is_file():
166
+ backup = path.with_name(path.name + ".lore.bak")
167
+ if not backup.exists():
168
+ shutil.copyfile(path, backup)
169
+ kept = _strip_toml_section(existing, "[mcp_servers.lore]")
170
+ rendered = ", ".join(json.dumps(part) for part in (args if args is not None else mcp_args()))
171
+ env = ""
172
+ if client.strip():
173
+ env = f"env = {{ LORE_CLIENT = {json.dumps(client.strip())} }}\n"
174
+ block = f"[mcp_servers.lore]\ncommand = {json.dumps(command)}\nargs = [{rendered}]\n{env}"
175
+ body = f"{kept}\n\n{block}" if kept else block
176
+ path.parent.mkdir(parents=True, exist_ok=True)
177
+ path.write_text(body if body.endswith("\n") else body + "\n", encoding="utf-8")
178
+
179
+
180
+ def _targets(home: Path, system: str) -> list[tuple[str, Path, str, str]]:
181
+ """(label, file, container key, editor). An editor is included when its folder exists."""
182
+ if system == "darwin":
183
+ desktop = home / "Library" / "Application Support" / "Claude"
184
+ vscode = home / "Library" / "Application Support" / "Code" / "User"
185
+ elif system == "windows":
186
+ desktop = home / "AppData" / "Roaming" / "Claude"
187
+ vscode = home / "AppData" / "Roaming" / "Code" / "User"
188
+ else:
189
+ desktop = home / ".config" / "Claude"
190
+ vscode = home / ".config" / "Code" / "User"
191
+ rows: list[tuple[str, Path, str, str]] = []
192
+ cursor = home / ".cursor"
193
+ if cursor.is_dir() or (cursor / "mcp.json").exists():
194
+ rows.append(("Cursor", cursor / "mcp.json", "mcpServers", "cursor"))
195
+ claude = home / ".claude.json"
196
+ if claude.exists() or (home / ".claude").is_dir():
197
+ rows.append(("Claude Code", claude, "mcpServers", "other"))
198
+ if desktop.is_dir():
199
+ rows.append(("Claude Desktop", desktop / "claude_desktop_config.json", "mcpServers", "other"))
200
+ if (home / ".codex").is_dir():
201
+ rows.append(("Codex", home / ".codex" / "config.toml", "", "codex"))
202
+ if vscode.is_dir():
203
+ rows.append(("VS Code", vscode / "mcp.json", "servers", "vscode"))
204
+ gemini = home / ".gemini" / "config"
205
+ if gemini.is_dir():
206
+ rows.append(("Antigravity", gemini / "mcp_config.json", "mcpServers", "other"))
207
+ return rows
208
+
209
+
210
+ def register_agents(
211
+ home: Path | None = None,
212
+ system: str | None = None,
213
+ ) -> dict[str, list[str]]:
214
+ """Write Lore into each detected editor. Other servers in those files stay."""
215
+ if home is None:
216
+ raw = os.environ.get("LORE_HOST_HOME", "")
217
+ home = Path(raw) if raw else Path.home()
218
+ host = host_system(system)
219
+ command, args = invocation()
220
+ written: list[str] = []
221
+ skipped: list[str] = []
222
+ for label, path, key, editor in _targets(home, host):
223
+ try:
224
+ if editor == "codex":
225
+ _write_codex(path, command, client=label, args=args)
226
+ else:
227
+ _write_json(path, key, mcp_entry(editor, command, client=label, args=args))
228
+ except (OSError, ValueError, json.JSONDecodeError):
229
+ skipped.append(f"{label}: {path}")
230
+ continue
231
+ written.append(str(path))
232
+ return {"written": written, "skipped": skipped}
lore/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """File-backed knowledge. No network and no database."""
lore/core/budget.py ADDED
@@ -0,0 +1,40 @@
1
+ """Count tokens for the 800-token answer cap.
2
+
3
+ The count is cl100k_base when the tiktoken encoder is installed. That is the
4
+ same encoding the previous Go engine used. If the encoder cannot be loaded,
5
+ the count is one token per three UTF-8 bytes, rounded up. Tests pin both
6
+ behaviors by name.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from functools import lru_cache
12
+ from typing import Protocol
13
+
14
+
15
+ class _Encoder(Protocol):
16
+ def encode(self, text: str) -> list[int]: ...
17
+
18
+
19
+ @lru_cache(maxsize=1)
20
+ def _encoder() -> _Encoder | None:
21
+ try:
22
+ import tiktoken
23
+ except ImportError:
24
+ return None
25
+ try:
26
+ return tiktoken.get_encoding("cl100k_base")
27
+ except (OSError, ValueError, RuntimeError):
28
+ return None
29
+
30
+
31
+ def count_tokens(text: str) -> int:
32
+ encoder = _encoder()
33
+ if encoder is not None:
34
+ return len(encoder.encode(text))
35
+ return _bytes_fallback(text)
36
+
37
+
38
+ def _bytes_fallback(text: str) -> int:
39
+ """One token per three UTF-8 bytes, rounded up. Used only if tiktoken is missing."""
40
+ return (len(text.encode("utf-8")) + 2) // 3
lore/core/codebases.py ADDED
@@ -0,0 +1,101 @@
1
+ """Codebases the person has asked Lore to map."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ from lore.core.personas import file_slug
10
+
11
+
12
+ def _file(project: Path) -> Path:
13
+ return project / ".lore" / "codebases.json"
14
+
15
+
16
+ def list_codebases(project: Path) -> list[dict[str, str]]:
17
+ path = _file(project)
18
+ if not path.is_file():
19
+ return []
20
+ try:
21
+ loaded = json.loads(path.read_text(encoding="utf-8"))
22
+ except json.JSONDecodeError:
23
+ return []
24
+ if not isinstance(loaded, list):
25
+ return []
26
+ rows: list[dict[str, str]] = []
27
+ for item in loaded:
28
+ if not isinstance(item, dict):
29
+ continue
30
+ ident = str(item.get("id", "")).strip()
31
+ name = str(item.get("name", "")).strip()
32
+ folder = str(item.get("path", "")).strip()
33
+ if ident and name and folder:
34
+ rows.append({"id": ident, "name": name, "path": folder})
35
+ return rows
36
+
37
+
38
+ def get_codebase(project: Path, ident: str) -> dict[str, str]:
39
+ for row in list_codebases(project):
40
+ if row["id"] == ident:
41
+ return row
42
+ raise FileNotFoundError(ident)
43
+
44
+
45
+ def add_codebase(project: Path, name: str, folder: Path) -> dict[str, str]:
46
+ title = name.strip()
47
+ if not title:
48
+ raise ValueError("A codebase needs a name.")
49
+ if not folder.is_dir():
50
+ raise ValueError("That path is not a folder.")
51
+ rows = list_codebases(project)
52
+ slug = file_slug(title)
53
+ taken = {row["id"] for row in rows}
54
+ ident = slug
55
+ number = 2
56
+ while ident in taken:
57
+ ident = f"{slug}-{number}"
58
+ number += 1
59
+ row = {"id": ident, "name": title, "path": str(folder.resolve())}
60
+ rows.append(row)
61
+ _write(project, rows)
62
+ return row
63
+
64
+
65
+ def rename_codebase(project: Path, ident: str, name: str) -> dict[str, str]:
66
+ title = name.strip()
67
+ if not title:
68
+ raise ValueError("A codebase needs a name.")
69
+ rows = list_codebases(project)
70
+ for row in rows:
71
+ if row["id"] == ident:
72
+ row["name"] = title
73
+ _write(project, rows)
74
+ return row
75
+ raise FileNotFoundError(ident)
76
+
77
+
78
+ def delete_codebase(project: Path, ident: str) -> dict[str, str]:
79
+ rows = list_codebases(project)
80
+ kept = [row for row in rows if row["id"] != ident]
81
+ if len(kept) == len(rows):
82
+ raise FileNotFoundError(ident)
83
+ removed = next(row for row in rows if row["id"] == ident)
84
+ _write(project, kept)
85
+ return removed
86
+
87
+
88
+ def remove_map(folder: Path) -> None:
89
+ """Delete the map files Lore wrote. Source files stay."""
90
+ graph = folder / "graphify-out"
91
+ if graph.is_dir():
92
+ shutil.rmtree(graph)
93
+ status = folder / ".lore" / "graph-status.json"
94
+ if status.is_file():
95
+ status.unlink()
96
+
97
+
98
+ def _write(project: Path, rows: list[dict[str, str]]) -> None:
99
+ path = _file(project)
100
+ path.parent.mkdir(parents=True, exist_ok=True)
101
+ path.write_text(json.dumps(rows, indent=2) + "\n", encoding="utf-8")
lore/core/config.py ADDED
@@ -0,0 +1,39 @@
1
+ """Read `.lore/lore.config.yaml`. Missing keys use the solo defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ DEFAULT_TOKEN_BUDGET = 800
11
+ DEFAULT_MAX_ITEMS = 8
12
+
13
+
14
+ def save_config(lore_dir: Path, token_budget: int, max_items: int) -> dict[str, int]:
15
+ if token_budget < 1 or max_items < 1:
16
+ raise ValueError("token_budget and max_items must be at least 1")
17
+ path = lore_dir / "lore.config.yaml"
18
+ loaded: dict[str, Any] = {}
19
+ if path.is_file():
20
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
21
+ if isinstance(raw, dict):
22
+ loaded = raw
23
+ loaded["token_budget"] = int(token_budget)
24
+ loaded["max_items"] = int(max_items)
25
+ path.parent.mkdir(parents=True, exist_ok=True)
26
+ path.write_text(yaml.safe_dump(loaded, sort_keys=False), encoding="utf-8")
27
+ return {"token_budget": int(token_budget), "max_items": int(max_items)}
28
+
29
+
30
+ def load_config(lore_dir: Path) -> dict[str, int]:
31
+ path = lore_dir / "lore.config.yaml"
32
+ loaded: dict[str, Any] = {}
33
+ if path.is_file():
34
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
35
+ if isinstance(raw, dict):
36
+ loaded = raw
37
+ budget = loaded.get("token_budget", DEFAULT_TOKEN_BUDGET)
38
+ items = loaded.get("max_items", DEFAULT_MAX_ITEMS)
39
+ return {"token_budget": int(budget), "max_items": int(items)}