Skip to content

Commit e13fbae

Browse files
srijanskclaude
andcommitted
harden: code fixes from the pre-launch audit
An adversarial pre-launch audit surfaced a cluster of correctness, cost, and robustness issues. Fixes, with new/updated tests for each: - Incremental compile cost (CODE-5): `lore watch` re-ran the LLM over the entire corpus every interval against the user's BYO key. Add a per-session extraction cache (sessions are immutable -> id is a safe key) so only newly-ingested sessions hit the model; `--rebuild` ignores it. Output stays idempotent; cost is now incremental. - First-run works (BUILD-1): make `anthropic` (the default provider) a base dependency and guard SDK imports with an actionable message instead of a raw ModuleNotFoundError. `pipx install crewlore` then `lore compile` works with just a key. - Real local provider (DOC-4): `model.provider: local` + `base_url` routes to any OpenAI-compatible endpoint (Ollama/LM Studio/vLLM). Was advertised but unimplemented, with a dead-end error message. - `lore serve --mcp` (CODE-1): the documented MCP flag didn't exist; the copy-paste mcp.json failed to launch. Added. - Scrub tool-call args (SEC-1): secrets passed as tool-call arguments live in event `meta`, which bypassed the scrubber. Walk `meta` recursively. Broaden coverage: AWS ASIA + secret-key assignments, all `xox?-` Slack tokens, quoted multi-word secrets (SEC-2/3/4). - Usage sidecar (CODE-7): `lore query` rewrote the git-tracked claims.jsonl on every call (usage bump). Move volatile usage stats to a gitignored sidecar; committed claims.jsonl stays byte-stable. - Timezone safety (CODE-2): timestamps are always tz-aware, so a timestampless transcript no longer crashes the actuation loop. - Compile resilience (CODE-6): one failing session (429/oversized context) no longer aborts the whole pass — skip and continue. - Unify fidelity definition (CODE-4): replay reuses the gate's `_canonical_form`, so the reported number matches what the gate enforces. - `lore --version` (HYG-1); drop dead `superseded` status (CODE-9); remove misleading `_normalize` alias (CODE-10); add py3.13 classifier. 131 tests passing (was 103). Lint clean. Clean wheel installs and runs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b2155ef commit e13fbae

20 files changed

Lines changed: 572 additions & 80 deletions

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,27 @@ classifiers = [
2222
"Programming Language :: Python :: 3.10",
2323
"Programming Language :: Python :: 3.11",
2424
"Programming Language :: Python :: 3.12",
25+
"Programming Language :: Python :: 3.13",
2526
"Topic :: Software Development :: Libraries :: Application Frameworks",
2627
]
2728
dependencies = [
2829
"typer>=0.12,<1.0",
2930
"rich>=13.0,<16.0",
3031
"pydantic>=2.0,<3.0",
3132
"pyyaml>=6.0,<7.0",
33+
# Anthropic is the default model provider, so it ships in the base install:
34+
# `pipx install crewlore` then `lore compile` works with just an API key.
35+
"anthropic>=0.39",
3236
]
3337

3438
[project.scripts]
3539
lore = "lore.cli:app"
3640

3741
[project.optional-dependencies]
42+
# OpenAI SDK — for `model.provider: openai` and for `local` (OpenAI-compatible endpoints).
3843
openai = ["openai>=1.0"]
39-
anthropic = ["anthropic>=0.30"]
4044
serve = ["mcp>=1.0"]
41-
dev = ["pytest>=8.0", "pytest-cov", "ruff"]
45+
dev = ["pytest>=8.0", "pytest-cov", "ruff", "openai>=1.0"]
4246

4347
[tool.hatch.build.targets.wheel]
4448
packages = ["src/lore"]

src/lore/actuation.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,18 @@
1414

1515
from __future__ import annotations
1616

17-
from datetime import datetime, timedelta
17+
from datetime import datetime, timedelta, timezone
1818

1919
from lore.schemas import Claim
2020

2121
_REINFORCE_PER_INFLUENCE = 0.1
2222

2323

24+
def _as_utc(dt: datetime) -> datetime:
25+
"""Coerce a possibly-naive datetime to UTC so aware/naive subtraction can't crash."""
26+
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
27+
28+
2429
def apply_lifecycle(
2530
claims: list[Claim],
2631
*,
@@ -44,7 +49,11 @@ def _step(c: Claim, now: datetime, max_unused_age: timedelta, override_threshold
4449
return c.model_copy(update={"status": "archived"})
4550

4651
# Never used and stale -> decay out of the active set.
47-
if u.times_served == 0 and c.observed_at is not None and (now - c.observed_at) > max_unused_age:
52+
if (
53+
u.times_served == 0
54+
and c.observed_at is not None
55+
and (_as_utc(now) - _as_utc(c.observed_at)) > max_unused_age
56+
):
4857
return c.model_copy(update={"status": "archived"})
4958

5059
# Used and valued -> reinforce.

src/lore/capture/adapters/claude_code.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from __future__ import annotations
1010

11-
from datetime import datetime
11+
from datetime import datetime, timezone
1212
from pathlib import Path
1313

1414
from lore.schemas import NSFEvent
@@ -22,9 +22,17 @@
2222

2323

2424
def _parse_ts(raw: str | None) -> datetime:
25+
"""Always return a timezone-aware UTC datetime.
26+
27+
A transcript record may omit `timestamp` (older/edited/third-party files), and
28+
a present timestamp may lack a zone. Both must yield an aware datetime, or the
29+
actuation lifecycle (which subtracts `now` in UTC) crashes with a naive-vs-aware
30+
TypeError downstream.
31+
"""
2532
if not raw:
26-
return datetime.fromtimestamp(0)
27-
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
33+
return datetime.fromtimestamp(0, tz=timezone.utc)
34+
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
35+
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
2836

2937

3038
class ClaudeCodeAdapter:

src/lore/cli.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import typer
1717

18+
from lore import __version__
1819
from lore.serve.server import KnowledgeServer
1920
from lore.store import LoreStore
2021

@@ -24,6 +25,22 @@
2425
TranscriptsOpt = typer.Option(None, "--transcripts", help="Override the transcripts dir.")
2526

2627

28+
def _version_callback(value: bool) -> None:
29+
if value:
30+
typer.echo(f"crewlore {__version__}")
31+
raise typer.Exit()
32+
33+
34+
@app.callback()
35+
def _main(
36+
version: bool = typer.Option(
37+
False, "--version", callback=_version_callback, is_eager=True,
38+
help="Show the crewlore version and exit.",
39+
),
40+
) -> None:
41+
"""Compile coding-agent sessions into team tribal knowledge, locally."""
42+
43+
2744
def _transcript_dir(store: LoreStore, override: Path | None) -> Path:
2845
if override is not None:
2946
return override
@@ -86,19 +103,25 @@ def query(text: str, repo: Path = RepoOpt, limit: int = 5):
86103
typer.echo(f" -> {c.action}")
87104

88105

89-
def _compile_once(store: LoreStore, transcript_dir: Path) -> dict:
106+
def _compile_once(store: LoreStore, transcript_dir: Path, *, rebuild: bool = False) -> dict:
90107
from lore.capture.adapters.claude_code import ClaudeCodeAdapter
91108
from lore.compile.run import auto_compile
92109

93110
extractor = _build_extractor(store)
94-
return auto_compile(store, extractor, ClaudeCodeAdapter(), transcript_dir)
111+
return auto_compile(store, extractor, ClaudeCodeAdapter(), transcript_dir, rebuild=rebuild)
95112

96113

97114
@app.command()
98-
def compile(repo: Path = RepoOpt, transcripts: Path = TranscriptsOpt): # noqa: A001
115+
def compile( # noqa: A001
116+
repo: Path = RepoOpt,
117+
transcripts: Path = TranscriptsOpt,
118+
rebuild: bool = typer.Option(
119+
False, "--rebuild", help="Ignore the extraction cache and re-extract all sessions."
120+
),
121+
):
99122
"""Ingest new transcripts, distill to claims + book, and prune (one pass)."""
100123
store = LoreStore(repo)
101-
stats = _compile_once(store, _transcript_dir(store, transcripts))
124+
stats = _compile_once(store, _transcript_dir(store, transcripts), rebuild=rebuild)
102125
typer.echo(
103126
f"ingested {stats['ingested']} new sessions "
104127
f"({stats['redactions']} redactions); "
@@ -112,12 +135,19 @@ def watch(
112135
transcripts: Path = TranscriptsOpt,
113136
interval: int = typer.Option(300, "--interval", help="Seconds between passes."),
114137
once: bool = typer.Option(False, "--once", help="Run a single pass and exit (cron mode)."),
138+
rebuild: bool = typer.Option(
139+
False, "--rebuild", help="Ignore the extraction cache and re-extract all sessions."
140+
),
115141
):
116-
"""Automatically compile on an interval — so nobody has to remember to."""
142+
"""Automatically compile on an interval — so nobody has to remember to.
143+
144+
Extraction is cached per session, so each pass only sends newly-ingested
145+
sessions to the model; cost is incremental, not per-corpus-per-interval.
146+
"""
117147
store = LoreStore(repo)
118148
tdir = _transcript_dir(store, transcripts)
119149
while True:
120-
stats = _compile_once(store, tdir)
150+
stats = _compile_once(store, tdir, rebuild=rebuild)
121151
typer.echo(
122152
f"[watch] +{stats['ingested']} sessions, "
123153
f"{stats['active']} active claims, {stats['conflicts']} conflicts"
@@ -132,8 +162,16 @@ def watch(
132162

133163

134164
@app.command()
135-
def serve(repo: Path = RepoOpt):
136-
"""Start the MCP server exposing query-time retrieval."""
165+
def serve(
166+
repo: Path = RepoOpt,
167+
mcp: bool = typer.Option(
168+
True, "--mcp", help="Run as an MCP server over stdio (the only mode today)."
169+
),
170+
):
171+
"""Start the MCP server exposing query-time retrieval to any MCP client."""
172+
if not mcp: # reserved for future non-MCP serve modes
173+
typer.echo("Only MCP serving is supported today; run without --no-mcp.")
174+
raise typer.Exit(1)
137175
try:
138176
from lore.serve.mcp_server import run_mcp
139177
except ImportError:

src/lore/compile/extractor.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,6 @@ def _canonical_form(text: str) -> str:
8585
return text.strip().lower()
8686

8787

88-
# Back-compat alias for the call sites that used _normalize() during build-up.
89-
_normalize = _canonical_form
90-
91-
9288
class LLMExtractor:
9389
def __init__(
9490
self, complete: Complete, *, author: str = "unknown", harness: str = "claude-code"
@@ -122,7 +118,7 @@ def extract(
122118
# when a long agent reply is split by a tool_call between segments)
123119
# The result: a quote that spans an agent reply punctuated by tool
124120
# calls still validates against the continuous prose.
125-
haystack = _normalize(
121+
haystack = _canonical_form(
126122
"\n".join(e.content for e in events if e.kind != "tool_call")
127123
)
128124
provenance = Provenance(session=session_id, author=self._author, harness=self._harness)
@@ -152,7 +148,7 @@ def _build_claim(self, item, provenance, observed_at, haystack) -> Claim | None:
152148
quote=a["quote"],
153149
)
154150
for a in item.get("anchors", [])
155-
if a.get("quote") and _normalize(a["quote"]) in haystack
151+
if a.get("quote") and _canonical_form(a["quote"]) in haystack
156152
]
157153
if not verified: # fidelity gate: no verbatim anchor -> reject
158154
return None

src/lore/compile/llm.py

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
"""Provider abstraction so lore is genuinely BYO-key / local-first: point it at
2-
Anthropic, OpenAI, or anything that can answer a `complete(prompt) -> str` call.
3-
Nothing routes through any lore-operated infrastructure because there is none.
4-
5-
SDKs are imported lazily so the package installs without them, and a missing key
6-
fails loudly with a clear message rather than silently producing no claims.
2+
Anthropic, OpenAI, or any OpenAI-compatible endpoint you run yourself (Ollama,
3+
LM Studio, vLLM, …) via `provider: local` + `base_url`. Nothing routes through
4+
any lore-operated infrastructure because there is none.
5+
6+
The default provider (Anthropic) ships as a base dependency so the headline
7+
`pipx install crewlore` → `lore compile` path works with just a key. The OpenAI
8+
SDK is an optional extra; if a provider needs an SDK that isn't installed, we
9+
raise a clear, actionable error instead of a raw ImportError traceback.
710
"""
811

912
from __future__ import annotations
@@ -14,30 +17,49 @@
1417

1518

1619
class CredentialsError(RuntimeError):
17-
"""Raised when no usable model credentials are configured."""
20+
"""Raised when model credentials / config are missing or a provider is unknown."""
1821

1922

2023
def build_complete(config: dict) -> Complete:
2124
model_cfg = (config or {}).get("model", {}) or {}
2225
provider = model_cfg.get("provider", "anthropic")
2326
name = model_cfg.get("name")
27+
base_url = model_cfg.get("base_url")
2428

2529
if provider == "anthropic":
2630
return _anthropic_complete(name or "claude-sonnet-4-6")
2731
if provider == "openai":
2832
return _openai_complete(name or "gpt-4o")
29-
raise CredentialsError(f"Unknown model provider '{provider}'.")
33+
if provider in ("local", "openai-compatible"):
34+
if not base_url:
35+
raise CredentialsError(
36+
"Provider 'local' needs `model.base_url` in .lore/config.yaml — point it at "
37+
"any OpenAI-compatible endpoint (e.g. http://localhost:11434/v1 for Ollama, "
38+
"or your LM Studio / vLLM server)."
39+
)
40+
return _openai_complete(name or "local-model", base_url=base_url)
41+
raise CredentialsError(
42+
f"Unknown model provider '{provider}'. Use 'anthropic', 'openai', or 'local' "
43+
"(an OpenAI-compatible endpoint configured via `model.base_url`)."
44+
)
3045

3146

3247
def _anthropic_complete(model: str) -> Complete:
3348
if not os.environ.get("ANTHROPIC_API_KEY"):
3449
raise CredentialsError(
35-
"No ANTHROPIC_API_KEY set. Export an API key, or set model.provider to a "
36-
"local provider in .lore/config.yaml. lore is BYO-key; nothing routes through us."
50+
"No ANTHROPIC_API_KEY set. Export an API key, switch to `model.provider: openai` "
51+
"(with OPENAI_API_KEY), or run a local model with `model.provider: local` + "
52+
"`model.base_url` in .lore/config.yaml. crewlore is BYO-key; nothing routes through us."
3753
)
3854

3955
def complete(prompt: str) -> str:
40-
import anthropic
56+
try:
57+
import anthropic
58+
except ImportError as exc: # pragma: no cover - anthropic is a base dependency
59+
raise CredentialsError(
60+
"The Anthropic SDK isn't importable. Reinstall crewlore, or: "
61+
"pip install 'anthropic>=0.39'."
62+
) from exc
4163

4264
client = anthropic.Anthropic()
4365
msg = client.messages.create(
@@ -51,17 +73,32 @@ def complete(prompt: str) -> str:
5173
return complete
5274

5375

54-
def _openai_complete(model: str) -> Complete:
55-
if not os.environ.get("OPENAI_API_KEY"):
76+
def _openai_complete(model: str, *, base_url: str | None = None) -> Complete:
77+
local = base_url is not None
78+
if not local and not os.environ.get("OPENAI_API_KEY"):
5679
raise CredentialsError(
57-
"No OPENAI_API_KEY set. Export an API key, or set model.provider to a "
58-
"local provider in .lore/config.yaml. lore is BYO-key; nothing routes through us."
80+
"No OPENAI_API_KEY set. Export an API key, or run a local OpenAI-compatible model "
81+
"by setting `model.provider: local` and `model.base_url` in .lore/config.yaml. "
82+
"crewlore is BYO-key; nothing routes through us."
5983
)
6084

6185
def complete(prompt: str) -> str:
62-
import openai
63-
64-
client = openai.OpenAI()
86+
try:
87+
import openai
88+
except ImportError as exc:
89+
raise CredentialsError(
90+
"The OpenAI SDK isn't installed (needed for the 'openai' and 'local' "
91+
"providers). Install it with: pipx inject crewlore openai "
92+
"(or pip install 'crewlore[openai]')."
93+
) from exc
94+
95+
if base_url:
96+
# Local OpenAI-compatible servers usually ignore the key, but the SDK requires one.
97+
client = openai.OpenAI(
98+
base_url=base_url, api_key=os.environ.get("OPENAI_API_KEY", "not-needed")
99+
)
100+
else:
101+
client = openai.OpenAI()
65102
resp = client.chat.completions.create(
66103
model=model,
67104
temperature=0, # deterministic — extraction is structured-output, not creative

src/lore/compile/pipeline.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,18 @@ def compile_sessions(
4646
# genuinely-conflicting claims never group. Seed from prior claims, then grow.
4747
known_topics: set[str] = {c.topic for c in candidates if c.topic}
4848
for session_id, events in sessions.items():
49-
if session_has_signal(events): # C0 lever 3: skip trivial sessions
49+
if not session_has_signal(events): # C0 lever 3: skip trivial sessions
50+
continue
51+
try:
5052
extracted = extractor.extract(events, session_id, sorted(known_topics))
51-
candidates.extend(extracted)
52-
known_topics.update(c.topic for c in extracted if c.topic)
53+
except Exception:
54+
# One problematic session (oversized context, transient 429/500) must
55+
# not abort the whole compile — skip it and continue. Nothing is cached
56+
# on failure, so the next pass retries it. Mirrors ingest's defensive
57+
# per-file posture.
58+
continue
59+
candidates.extend(extracted)
60+
known_topics.update(c.topic for c in extracted if c.topic)
5361

5462
claims = _dedup_and_score(candidates)
5563
conflicts = _detect_conflicts(claims)

0 commit comments

Comments
 (0)