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.
- lore/__init__.py +3 -0
- lore/__main__.py +43 -0
- lore/connect.py +232 -0
- lore/core/__init__.py +1 -0
- lore/core/budget.py +40 -0
- lore/core/codebases.py +101 -0
- lore/core/config.py +39 -0
- lore/core/context.py +248 -0
- lore/core/decisions.py +60 -0
- lore/core/graph.py +184 -0
- lore/core/idempotency.py +36 -0
- lore/core/inbox.py +333 -0
- lore/core/okf.py +64 -0
- lore/core/onboarding.py +51 -0
- lore/core/paths.py +75 -0
- lore/core/personas.py +298 -0
- lore/core/picker.py +53 -0
- lore/core/provider.py +372 -0
- lore/core/tests/pack.py +7 -0
- lore/core/tests/test_connect.py +67 -0
- lore/core/tests/test_context.py +95 -0
- lore/core/tests/test_files.py +123 -0
- lore/core/tests/test_graph.py +79 -0
- lore/core/tests/test_inbox.py +118 -0
- lore/core/tests/test_install.py +43 -0
- lore/core/tests/test_library_flow.py +130 -0
- lore/core/tests/test_mcp.py +101 -0
- lore/core/tests/test_picker.py +32 -0
- lore/core/tests/test_provider.py +175 -0
- lore/core/usage.py +93 -0
- lore/core/workspace.py +88 -0
- lore/install_cmd.py +101 -0
- lore/mcp.py +5 -0
- lore/mcp_server.py +121 -0
- lore/py.typed +0 -0
- lore/setup/__init__.py +1 -0
- lore/setup/graph_job.py +96 -0
- lore/setup/middleware.py +30 -0
- lore/setup/settings.py +21 -0
- lore/setup/tests/test_site.py +217 -0
- lore/setup/urls.py +40 -0
- lore/setup/views.py +715 -0
- lore/setup_cmd.py +221 -0
- lore/web_src/app/codebase/[id]/page.tsx +154 -0
- lore/web_src/app/codebase/page.tsx +91 -0
- lore/web_src/app/connect/page.tsx +95 -0
- lore/web_src/app/globals.css +129 -0
- lore/web_src/app/layout.tsx +36 -0
- lore/web_src/app/locked/page.tsx +16 -0
- lore/web_src/app/page.tsx +176 -0
- lore/web_src/app/personas/[id]/page.tsx +181 -0
- lore/web_src/app/personas/page.tsx +121 -0
- lore/web_src/app/replay/page.tsx +55 -0
- lore/web_src/app/settings/page.tsx +278 -0
- lore/web_src/app/skills/[...id]/page.tsx +124 -0
- lore/web_src/app/skills/page.tsx +248 -0
- lore/web_src/components/Shell.tsx +53 -0
- lore/web_src/components/app-sidebar.tsx +100 -0
- lore/web_src/components/error-alert.tsx +10 -0
- lore/web_src/components/folder-picker.tsx +48 -0
- lore/web_src/components/onboarding.tsx +675 -0
- lore/web_src/components/site-header.tsx +55 -0
- lore/web_src/components/ui/alert.tsx +75 -0
- lore/web_src/components/ui/avatar.tsx +108 -0
- lore/web_src/components/ui/badge.tsx +51 -0
- lore/web_src/components/ui/breadcrumb.tsx +124 -0
- lore/web_src/components/ui/button.tsx +57 -0
- lore/web_src/components/ui/card.tsx +102 -0
- lore/web_src/components/ui/dropdown-menu.tsx +267 -0
- lore/web_src/components/ui/field.tsx +238 -0
- lore/web_src/components/ui/input.tsx +19 -0
- lore/web_src/components/ui/label.tsx +19 -0
- lore/web_src/components/ui/scroll-area.tsx +54 -0
- lore/web_src/components/ui/select.tsx +200 -0
- lore/web_src/components/ui/separator.tsx +24 -0
- lore/web_src/components/ui/sheet.tsx +138 -0
- lore/web_src/components/ui/sidebar.tsx +723 -0
- lore/web_src/components/ui/skeleton.tsx +13 -0
- lore/web_src/components/ui/sonner.tsx +49 -0
- lore/web_src/components/ui/table.tsx +115 -0
- lore/web_src/components/ui/textarea.tsx +17 -0
- lore/web_src/components/ui/tooltip.tsx +65 -0
- lore/web_src/components.json +25 -0
- lore/web_src/hooks/use-mobile.ts +19 -0
- lore/web_src/lib/api.ts +62 -0
- lore/web_src/lib/providers.ts +20 -0
- lore/web_src/lib/routes.ts +11 -0
- lore/web_src/lib/utils.ts +1 -0
- lore/web_src/middleware.ts +29 -0
- lore/web_src/next-env.d.ts +6 -0
- lore/web_src/next.config.ts +26 -0
- lore/web_src/package-lock.json +5703 -0
- lore/web_src/package.json +31 -0
- lore/web_src/postcss.config.mjs +1 -0
- lore/web_src/tsconfig.json +40 -0
- lore_solo-0.1.0.dist-info/METADATA +122 -0
- lore_solo-0.1.0.dist-info/RECORD +101 -0
- lore_solo-0.1.0.dist-info/WHEEL +5 -0
- lore_solo-0.1.0.dist-info/entry_points.txt +2 -0
- lore_solo-0.1.0.dist-info/licenses/LICENSE +21 -0
- lore_solo-0.1.0.dist-info/top_level.txt +1 -0
lore/__init__.py
ADDED
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)}
|