cribsheet 0.2.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.
crib/app.py ADDED
@@ -0,0 +1,1594 @@
1
+ """Crib — the core service. Implements the tool verbs (DESIGN §5).
2
+
3
+ Both the MCP server and the CLI call into this; tests exercise it directly. All
4
+ writes go through `_write_note` so every mutation stashes a version and funnels
5
+ through the one hash-gated `index_file`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import datetime
12
+ import os
13
+ import sys
14
+ import threading
15
+ from dataclasses import dataclass, replace
16
+ from pathlib import Path
17
+ from typing import Any, Callable
18
+
19
+ from . import claudemem, notes
20
+ from .chunk import section_line_map
21
+ from .claudemem import MemoryBindings
22
+ from .codeindexer import CodeIndexer
23
+ from .codestore import CodeStore, _ResidentCode
24
+ from .config import (Config, CribLink, ProjectConfig, portable_path,
25
+ resolve_project)
26
+ from .project_services import ProjectServices
27
+ from .refs import Refs
28
+ from .embed import build_embedder
29
+ from .gitbacking import GitBacking
30
+ from .codequery import CodeQuery
31
+ from .indexer import IndexEngine, IndexResult
32
+ from .learnings import Learnings
33
+ from .notes import Note
34
+ from .notestore import NoteStore
35
+ from .paths import Paths
36
+ from .sources import SRC_PREFIX, SourceRoots, src_relpath
37
+ from .store import Hit, Store
38
+ from .util import derived_ulid
39
+ from .versions import VersionRing
40
+ from .watch import CodeWatcher, Watcher
41
+
42
+
43
+ def _slug(title: str) -> str:
44
+ keep = "".join(c if c.isalnum() or c in " -_" else "" for c in title)
45
+ return "-".join(keep.lower().split()) or "note"
46
+
47
+
48
+ # Built-in distill instruction (knowledge-capture §4); a project's `.cribproject`
49
+ # `distill_prompt` overrides it.
50
+ DEFAULT_DISTILL_PROMPT = (
51
+ "Revise this note to be tighter and cleaner while preserving meaning. "
52
+ "Compress verbose prose, merge duplicated points, normalize structure. "
53
+ "KEEP every fact, decision, gotcha, and API detail; DROP deliberation, "
54
+ "hedging, and restated context. Preserve code blocks and commands VERBATIM. "
55
+ "Return ONLY the revised note body in markdown — no preamble, no fences."
56
+ )
57
+
58
+
59
+ @dataclass
60
+ class LookupHit:
61
+ project: str
62
+ relpath: str
63
+ heading: str
64
+ title: str
65
+ snippet: str
66
+ score: float
67
+ line_start: int | None = None # 1-based span of the section in the file,
68
+ line_end: int | None = None # resolved against current disk (None if gone)
69
+
70
+
71
+ def _resolve_embed_config(config: Config) -> Any:
72
+ """Pick the embed model by *active profile*, reusing the `models.toml`
73
+ profiles the generation layer already uses (`select(profile, "embed")`). The
74
+ profile is chosen **externally** — `$CRIB_PROFILE` (a per-host picker, the
75
+ Python analog of zsh-ai's per-host `zstyle ':zsh-ai:*' profile`), falling back
76
+ to `[generate].profile`. A capable box picks a profile whose `embed` names a
77
+ bigger model; a Pi sets nothing and keeps `[embed].model`. No model name lives
78
+ outside the config — only the profile does. Falls back to `[embed]` on any
79
+ miss (no profile, no `embed` key, unreadable config)."""
80
+ profile = os.environ.get("CRIB_PROFILE") or config.generate.profile
81
+ if profile and config.generate.config:
82
+ try:
83
+ from llmkit.bridge import load
84
+ conf = load(str(Path(config.generate.config).expanduser()))
85
+ spec = conf.select(profile, "embed")
86
+ if spec:
87
+ return replace(config.embed, model=spec)
88
+ except Exception: # noqa: BLE001 — profile embed is best-effort; fall back
89
+ pass
90
+ return config.embed
91
+
92
+
93
+ class Crib:
94
+ def __init__(self, paths: Paths, config: Config, store: Store) -> None:
95
+ self.paths = paths
96
+ self.config = config
97
+ self.store = store
98
+ self.embedder = build_embedder(_resolve_embed_config(config))
99
+ self.index = IndexEngine(store, self.embedder,
100
+ config.chunk.window_words,
101
+ config.chunk.overlap_words,
102
+ keyword_terms=self._keyword_terms,
103
+ summary_terms=self._summary_terms)
104
+ self.git = GitBacking(paths.data_dir)
105
+ self.versions = VersionRing(paths.versions_dir, config.versions_keep)
106
+ # Note-file store: path resolution + the write path (stash→save→index) over the
107
+ # shared backends (store/index/versions stay Crib attrs — retrieval, docs,
108
+ # import, generation all use them). Crib keeps delegators (below).
109
+ self.notestore = NoteStore(paths, store, self.index, self.versions)
110
+ self.memory_bindings = MemoryBindings(paths.data_dir / "memory-bindings.json")
111
+ self._reranker: Any = None # lazy cross-encoder, warm for the daemon
112
+ self._watcher: Watcher | None = None
113
+ self._code_watcher: CodeWatcher | None = None
114
+ self._describe_q: Any = None # DescribeQueue, started by the daemon
115
+ self._mirror: Any = None # MemoryMirror, started by the daemon
116
+ self._on_close: Callable[[], None] | None = None
117
+ # The code subsystem's shared per-project state (resident cache, freshness
118
+ # epoch, write locks, in-flight/sweep tracking) — owned by CodeStore; the
119
+ # methods below reach through `self.code` (codestore.py).
120
+ self.code = CodeStore(paths, config)
121
+ # Cross-project refs (.crib refs:) — resolution + edge attribution. Needs two
122
+ # Crib-owned callables it can't own: a ref's resident cache (carries the
123
+ # pipeline revalidate hook) and nested-.crib boundary detection (shared with
124
+ # code enumeration). Crib keeps delegators (below) for its many callers.
125
+ self.refs = Refs(paths, self._resident_code, self._nested_project_roots)
126
+ # The project-layer surface the indexing pipeline depends on — narrow deps
127
+ # (refs + code + config for resolution/ref-context, plus two injected callables
128
+ # for enumeration + watcher registration). No back-reference to Crib.
129
+ self.services = ProjectServices(self.refs, self.code, paths, config,
130
+ self._enumerate_code_files,
131
+ self._register_code_root)
132
+ # The code-index pipeline (extract → describe → persist), over CodeStore +
133
+ # ProjectServices. Crib keeps delegators (below) so the watcher, the resident
134
+ # revalidate hook, and project setup/index call it unchanged.
135
+ self.indexer = CodeIndexer(self.services)
136
+ # Durable symbol learnings (notes under code-learnings/), over refs + notestore.
137
+ # Crib keeps resolve_project + delegate public wrappers (code_append/…).
138
+ self.learnings = Learnings(paths, self.refs, self.notestore)
139
+ # Code-index queries (lookup/xref/dossier/graph) over refs + learnings + the
140
+ # resident cache. Crib keeps resolve_project + delegate public wrappers.
141
+ self.query = CodeQuery(self.refs, self.learnings, self.embedder,
142
+ self._resident_code, self._require_code_index)
143
+
144
+ # --- construction ------------------------------------------------------
145
+ @classmethod
146
+ def open(cls, store: Store | None = None) -> "Crib":
147
+ paths = Paths.resolve().ensure()
148
+ config = Config.load(paths.config_file)
149
+ on_close: Callable[[], None] | None = None
150
+ if store is None:
151
+ store, on_close = _build_store(paths, config)
152
+ crib = cls(paths, config, store)
153
+ crib._on_close = on_close
154
+ return crib
155
+
156
+ # --- lifecycle ---------------------------------------------------------
157
+ def start_watchers(self, loop: asyncio.AbstractEventLoop) -> None:
158
+ """Start the file watcher on `loop` — the SAME loop the tools run on, so
159
+ the per-path index locks coordinate writers and watcher (DESIGN §4)."""
160
+ if self._watcher is not None:
161
+ return
162
+ self._watcher = Watcher(self.paths.projects_dir, self._on_fs_change, loop)
163
+ self._watcher.start()
164
+ # Code watcher — reindexes source on edit (notes-watcher reloads notes,
165
+ # code-watcher reindexes code). Seed it with every code-indexed project's root.
166
+ self._code_watcher = CodeWatcher(self._on_code_change, loop)
167
+ from .codeindex import SymbolIndex
168
+ for p in self.projects():
169
+ name = p["project"] if isinstance(p, dict) else p
170
+ root = SymbolIndex(self.paths.project_dir(name)).source_root()
171
+ if root is not None:
172
+ self._code_watcher.watch_root(name, root)
173
+ self._code_watcher.start()
174
+ # Deferred describe: the code-watcher persists STRUCTURE eagerly and hands the
175
+ # changed symbols to this per-file backoff queue, so an edit burst coalesces to
176
+ # one focused LLM describe once the file settles (docs § Deferred describe).
177
+ from .describe_queue import DescribeQueue
178
+ gen = self.config.generate
179
+ self._describe_q = DescribeQueue(loop, self.indexer._describe_and_patch,
180
+ base=gen.describe_backoff_base,
181
+ cap=gen.describe_backoff_cap)
182
+ self.indexer.set_describe_queue(self._describe_q)
183
+ # Self-heal: a crash (or clean stop) mid-window leaves symbols with structure
184
+ # but a blank description — re-drive them so nothing stays undescribed.
185
+ loop.create_task(self._describe_backlog())
186
+
187
+ async def _on_fs_change(self, project: str, relpath: str) -> None:
188
+ await self.index.index_file(project, self.notes_dir(project), relpath)
189
+
190
+ async def _on_code_change(
191
+ self, project: str, changes: dict[str, tuple[str, bool]]) -> None:
192
+ """Reindex (or drop) the source files the watcher coalesced for a project —
193
+ eager counterpart to the lazy query-time revalidation. Off the loop;
194
+ best-effort (a transient syntax error mid-edit just leaves the prior entry
195
+ until the next save). A batch too large to reindex file-by-file (a branch
196
+ switch) collapses to a single revalidation sweep."""
197
+ from .codeindex import _POOL
198
+ from .watch import CODE_BATCH_FALLBACK
199
+ # Pump the same events into the warm LSP sessions (docs §3.2) BEFORE
200
+ # reindexing, so a server that doesn't self-watch the fs invalidates its
201
+ # workspace index — cross-file edges then resolve against current code.
202
+ by_root: dict[str, list[tuple[str, int]]] = {}
203
+ for relpath, (root, deleted) in changes.items():
204
+ if not relpath.startswith("\x00doc\x00"):
205
+ by_root.setdefault(root, []).append((relpath, 3 if deleted else 2))
206
+ for root, events in by_root.items():
207
+ _POOL.notify_changes(Path(root), events)
208
+ if len(changes) > CODE_BATCH_FALLBACK:
209
+ try:
210
+ await asyncio.to_thread(self._revalidate, project)
211
+ except Exception: # noqa: BLE001 — never let a watcher event crash the loop
212
+ pass
213
+ return
214
+ for relpath, (root, deleted) in changes.items():
215
+ try:
216
+ if relpath.startswith("\x00doc\x00"): # in-situ doc (see CodeWatcher._decode)
217
+ # index_file is async (asyncio locks) — await on THIS loop, don't
218
+ # thread it (a per-thread asyncio.run would strand stale-loop locks).
219
+ rel_to_repo = relpath[len("\x00doc\x00"):]
220
+ src_rel = src_relpath(Path(root).name, rel_to_repo)
221
+ await self.index.index_file(
222
+ project, self.notes_dir(project), src_rel,
223
+ content_path=self.abspath(project, src_rel))
224
+ elif deleted and not (Path(root) / relpath).exists():
225
+ await asyncio.to_thread(self._drop_file, project, relpath)
226
+ else:
227
+ # NB: a `deleted` flag for a file that EXISTS is a spurious
228
+ # delete — macOS FSEvents coalesces a rename-style save into
229
+ # flag bundles that watchdog re-expands in arbitrary order,
230
+ # and the batch's last-event-wins can land on the delete.
231
+ # Trusting it evicted whole files' symbols; the file's actual
232
+ # state at dispatch time (post-debounce) is the truth.
233
+ # DEFER the LLM describe: persist structure now, queue the
234
+ # description pass with per-file backoff (the live edit path is
235
+ # exactly where rapid saves would otherwise slam the LLM).
236
+ await asyncio.to_thread(
237
+ self._index_code_file_tracked, Path(root), relpath, project,
238
+ True, None, "defer")
239
+ except Exception: # noqa: BLE001 — one bad file never aborts the batch
240
+ pass
241
+
242
+ def _register_code_root(self, project: str, root: str | Path) -> None:
243
+ """Watch a repo's source root as soon as it's indexed (so a mid-session
244
+ onboard starts live-updating immediately)."""
245
+ if self._code_watcher is not None:
246
+ self._code_watcher.watch_root(project, root)
247
+
248
+ async def _describe_backlog(self) -> None:
249
+ """Re-drive symbols a stop/crash left undescribed. The deferred-describe path
250
+ blanks a changed symbol's description on the structural write, so `content_hash
251
+ present + empty description` is the durable "never got described" signal — one
252
+ inline reindex per affected file catches them up. A no-op in the common case
253
+ (nothing blank), so it's cheap to run on every start."""
254
+ from .codeindex import SymbolIndex
255
+ for p in self.projects():
256
+ name = p["project"] if isinstance(p, dict) else p
257
+ try:
258
+ si = SymbolIndex(self.paths.project_dir(name))
259
+ root = si.source_root()
260
+ if root is None:
261
+ continue
262
+ files = sorted({e["file"] for e in si.all()
263
+ if e.get("content_hash") and not e.get("description")
264
+ and e.get("file")})
265
+ for rel in files:
266
+ try: # inline: describe now, no queue
267
+ await asyncio.to_thread(self._index_code_file_tracked,
268
+ root, rel, name, True)
269
+ except Exception: # noqa: BLE001 — one bad file never aborts catch-up
270
+ pass
271
+ except Exception: # noqa: BLE001 — best-effort; missing/odd index is fine
272
+ pass
273
+
274
+ async def start_memory_mirror(self, loop: asyncio.AbstractEventLoop) -> None:
275
+ """Catch up + live-mirror bound Claude harness memory dirs (DESIGN §13).
276
+ No-op without bindings (`crib import-memory` opts repos in)."""
277
+ if self._mirror is not None or not self.config.memory.watch:
278
+ return
279
+ from .memmirror import MemoryMirror
280
+
281
+ async def sync(root: Path, project: str) -> Any:
282
+ return await self.import_claude_memory(project=project, root=root)
283
+
284
+ self._mirror = MemoryMirror(self.memory_bindings, sync, loop)
285
+ await self._mirror.catch_up()
286
+ self._mirror.start()
287
+
288
+ def stop_watchers(self) -> None:
289
+ if self._watcher is not None:
290
+ self._watcher.stop()
291
+ self._watcher = None
292
+ if self._code_watcher is not None:
293
+ self._code_watcher.stop()
294
+ self._code_watcher = None
295
+ if self._describe_q is not None:
296
+ self._describe_q.stop()
297
+ self._describe_q = None
298
+ if self._mirror is not None:
299
+ self._mirror.stop()
300
+ self._mirror = None
301
+
302
+ def close(self) -> None:
303
+ """Stop watchers, shut warm LSP sessions down, and release the shared
304
+ Chroma refcount, if any."""
305
+ self.stop_watchers()
306
+ from .codeindex import _POOL
307
+ _POOL.close_all()
308
+ if self._on_close is not None:
309
+ self._on_close()
310
+ self._on_close = None
311
+
312
+ # --- helpers -----------------------------------------------------------
313
+ def resolve_project(self, project: str | None, cwd: Path | None = None) -> str:
314
+ return resolve_project(self.config, project, cwd)
315
+
316
+ def notes_dir(self, project: str) -> Path:
317
+ return self.notestore.dir(project)
318
+
319
+ def _keyword_terms(self, project: str, section_hash: str,
320
+ labels: tuple[str, ...]) -> list[str]:
321
+ """Per-section keyword_index terms for the given labels — the BM25 feed
322
+ (§3.1). Read from the section-addressed TOML store; empty when a section
323
+ has no keyword set for a label yet (graceful: BM25 falls back to
324
+ body+heading)."""
325
+ from .section_index import SectionIndex
326
+ return SectionIndex(self.paths.project_dir(project), "keyword_index") \
327
+ .terms_for(section_hash, list(labels))
328
+
329
+ def _summary_terms(self, project: str, section_hash: str,
330
+ labels: tuple[str, ...]) -> list[str]:
331
+ """Per-section summary_index rephrasings for the given labels — the dense
332
+ alias feed (§3). Read from the section-addressed TOML store; empty when a
333
+ section has no summary for a label yet."""
334
+ from .section_index import SectionIndex
335
+ return SectionIndex(self.paths.project_dir(project), "summary_index") \
336
+ .terms_for(section_hash, list(labels))
337
+
338
+ def _source_roots(self, project: str) -> "SourceRoots":
339
+ """Per-project registry of docs indexed in-situ (prefix -> repo root)."""
340
+ return self.notestore.source_roots(project)
341
+
342
+ def abspath(self, project: str, relpath: str) -> Path:
343
+ return self.notestore.abspath(project, relpath)
344
+
345
+ async def _write_note(self, project: str, relpath: str, note: Note) -> IndexResult:
346
+ return await self.notestore.write(project, relpath, note)
347
+
348
+ # --- tool verbs --------------------------------------------------------
349
+ # near-duplicate nudge: a stored note whose probe matches an existing note
350
+ # this closely is flagged in the result (the descriptions preach append/edit
351
+ # over duplicating; this is what detects it).
352
+ DEDUPE_WARN_SCORE = 0.85
353
+
354
+ def project_is_new(self, proj: str) -> bool:
355
+ """True if `proj` has no notes dir yet (a write would create it)."""
356
+ return not self.paths.notes_dir(proj).exists()
357
+
358
+ def _unique_relpath(self, proj: str, slug: str) -> str:
359
+ """`<slug>.md`, with a numeric suffix only on collision — predictable and
360
+ hand-referenceable. Stable identity is the frontmatter `id`, not the path."""
361
+ base = self.notes_dir(proj)
362
+ if not (base / f"{slug}.md").exists():
363
+ return f"{slug}.md"
364
+ i = 2
365
+ while (base / f"{slug}-{i}.md").exists():
366
+ i += 1
367
+ return f"{slug}-{i}.md"
368
+
369
+ def _similar(self, proj: str, content: str, exclude: str) -> list[dict[str, Any]]:
370
+ """Near-duplicate hints for a just-stored note (excludes the note itself)."""
371
+ probe = content.strip().splitlines()[0][:200] if content.strip() else ""
372
+ if not probe:
373
+ return []
374
+ try:
375
+ hits = self.lookup(probe, project=proj, k=4)
376
+ except Exception: # noqa: BLE001 — a nudge must never fail the store
377
+ return []
378
+ return [{"relpath": h.relpath, "heading": h.heading, "score": h.score}
379
+ for h in hits
380
+ if h.relpath != exclude and h.score >= self.DEDUPE_WARN_SCORE]
381
+
382
+ async def store_note(self, content: str, title: str | None = None,
383
+ project: str | None = None, tags: list[str] | None = None,
384
+ cwd: Path | None = None) -> dict[str, Any]:
385
+ proj = self.resolve_project(project, cwd)
386
+ created = self.project_is_new(proj)
387
+ title = title or content.strip().splitlines()[0][:60] if content.strip() else "note"
388
+ relpath = self._unique_relpath(proj, _slug(title))
389
+ fm: dict[str, Any] = {"title": title, "source": "manual"}
390
+ if tags:
391
+ fm["tags"] = tags
392
+ note = Note(path=self.abspath(proj, relpath), frontmatter=fm, body=content)
393
+ res = await self._write_note(proj, relpath, note)
394
+ return {"project": proj, "relpath": relpath, "indexed": res.upserted,
395
+ "created": created, "similar": self._similar(proj, content, relpath)}
396
+
397
+ async def move_note(self, relpath: str, to_project: str | None = None,
398
+ to_relpath: str | None = None, project: str | None = None,
399
+ cwd: Path | None = None) -> dict[str, Any]:
400
+ """Relocate a note across projects and/or rename it, preserving its `id`
401
+ (and thus version-ring history). One-way: write destination, drop source."""
402
+ src_proj = self.resolve_project(project, cwd)
403
+ return await self.notestore.move(src_proj, relpath,
404
+ to_project or src_proj, to_relpath or relpath)
405
+
406
+ async def append_note(self, relpath: str, content: str,
407
+ heading: str | None = None, project: str | None = None,
408
+ cwd: Path | None = None) -> dict[str, Any]:
409
+ proj = self.resolve_project(project, cwd)
410
+ path = self.abspath(proj, relpath)
411
+ note = notes.load(path) if path.exists() else Note(
412
+ path=path, frontmatter={"source": "appended"}, body="")
413
+ block = f"\n\n## {heading}\n{content}" if heading else f"\n\n{content}"
414
+ note.body = note.body.rstrip() + block
415
+ res = await self._write_note(proj, relpath, note)
416
+ return {"project": proj, "relpath": relpath, "indexed": res.upserted}
417
+
418
+ async def edit_note(self, relpath: str, new_content: str,
419
+ project: str | None = None, cwd: Path | None = None) -> dict[str, Any]:
420
+ """Replace raw file content (frontmatter preserved if present in input)."""
421
+ proj = self.resolve_project(project, cwd)
422
+ fm, body = notes.parse(new_content)
423
+ path = self.abspath(proj, relpath)
424
+ if not fm and path.exists():
425
+ fm = notes.load(path).frontmatter # keep existing frontmatter
426
+ note = Note(path=path, frontmatter=fm, body=body)
427
+ res = await self._write_note(proj, relpath, note)
428
+ return {"project": proj, "relpath": relpath, "indexed": res.upserted}
429
+
430
+ async def forget(self, relpath: str, project: str | None = None,
431
+ cwd: Path | None = None) -> dict[str, Any]:
432
+ """Delete a note: remove it from disk and drop its chunks from the index.
433
+
434
+ The current content is stashed to the version ring first (keyed by note
435
+ id), so a forgotten note's bytes survive and can be recovered. Deletion
436
+ of the index entry happens through the same `index_file` — once the file
437
+ is gone, it sees a missing path and drops all chunks for that relpath."""
438
+ proj = self.resolve_project(project, cwd)
439
+ return await self.notestore.delete(proj, relpath)
440
+
441
+ def read_note(self, relpath: str, project: str | None = None,
442
+ cwd: Path | None = None) -> str:
443
+ proj = self.resolve_project(project, cwd)
444
+ return self.notestore.read(proj, relpath)
445
+
446
+ def locate(self, relpath: str, project: str | None = None,
447
+ cwd: Path | None = None) -> str:
448
+ proj = self.resolve_project(project, cwd)
449
+ return str(self.abspath(proj, relpath))
450
+
451
+ async def reindex(self, relpath: str | None = None, project: str | None = None,
452
+ cwd: Path | None = None) -> dict[str, Any]:
453
+ """Reindex a note, or fully reconcile a project when relpath is None.
454
+
455
+ Full reconcile walks the UNION of on-disk notes and indexed paths, so it
456
+ catches files added/edited while crib was down AND drops orphaned chunks
457
+ for notes deleted off disk. All idempotent via the hash gate (§4)."""
458
+ proj = self.resolve_project(project, cwd)
459
+ return await self.notestore.reindex(proj, relpath)
460
+
461
+ async def reconcile_all(self) -> dict[str, Any]:
462
+ """Startup/post-pull sweep across every project — catch up on offline
463
+ note changes, then eagerly rebuild merge-dirtied code files (a pull
464
+ that merged divergent symbol records lands here via the CLI's
465
+ post-pull reconcile)."""
466
+ projects = sorted(set(self.projects()) | self._indexed_projects())
467
+ total: dict[str, Any] = {"projects": len(projects), "changed": 0, "removed": 0}
468
+ for proj in projects:
469
+ r = await self.reindex(project=proj)
470
+ total["changed"] += r["changed"]
471
+ total["removed"] += r["removed"]
472
+ dirty = await self._reindex_dirty_code()
473
+ if dirty:
474
+ total["code_files_rebuilt"] = dirty
475
+ return total
476
+
477
+ async def _reindex_dirty_code(self) -> dict[str, int]:
478
+ """Rebuild every code file carrying a merge-dirtied symbol (blank
479
+ `content_hash`, written by the sync merge driver on divergent code
480
+ states) — CONCURRENTLY, bounded by [generate].concurrency, so the
481
+ post-pull reconcile pays this cost in parallel instead of the first
482
+ code query absorbing it serially in `_revalidate` (which remains the
483
+ lazy backstop for pulls done outside crib). Best-effort per file.
484
+ → {project: files rebuilt} for the projects that had any."""
485
+ from .codeindex import SymbolIndex
486
+ sem = asyncio.Semaphore(max(1, self.config.generate.concurrency))
487
+ out: dict[str, int] = {}
488
+ for proj in self.projects():
489
+ store = SymbolIndex(self.paths.project_dir(proj))
490
+ if not store.is_populated():
491
+ continue
492
+ root = store.source_root()
493
+ if root is None or not root.exists():
494
+ continue # index synced from another machine — lazy path
495
+ src_root: Path = root # narrowed rebind (mypy: closure default)
496
+ files = sorted({e["file"] for e in store.all()
497
+ if e.get("file") and not e.get("content_hash")})
498
+ if not files:
499
+ continue
500
+
501
+ async def _one(rel: str, proj: str = proj, root: Path = src_root) -> bool:
502
+ async with sem:
503
+ try:
504
+ await asyncio.to_thread(
505
+ self._index_code_file_tracked, root, rel, proj, True)
506
+ return True
507
+ except Exception: # noqa: BLE001 — the lazy gate backstops
508
+ return False
509
+ done = await asyncio.gather(*(_one(f) for f in files))
510
+ out[proj] = sum(done)
511
+ return out
512
+
513
+ def _indexed_projects(self) -> set[str]:
514
+ out: set[str] = set()
515
+ for m in self.store.get_meta({}).values():
516
+ if p := m.get("project"):
517
+ out.add(p)
518
+ return out
519
+
520
+ @property
521
+ def reranker(self) -> Any:
522
+ """Lazy reranker, built once and kept warm (daemon-resident)."""
523
+ if self._reranker is None:
524
+ from .retrieve import build_reranker
525
+ self._reranker = build_reranker(self.config.retrieve.rerank_model)
526
+ return self._reranker
527
+
528
+ def _rerank(self, query: str, hits: list[Hit]) -> list[Hit]:
529
+ """Blend the cross-encoder into the ranking by RRF-fusing its order with
530
+ the existing fused order, rather than letting it fully reorder — so the
531
+ reranker is a third voter that can *promote* a better match but can't
532
+ single-handedly *break* a strong hybrid result on one bad judgment. Only
533
+ the top `rerank_top_n` are scored. Degrades to input order if unavailable."""
534
+ n = self.config.retrieve.rerank_top_n
535
+ head = hits[:n]
536
+ if not head:
537
+ return hits
538
+ try:
539
+ scores = self.reranker.scores(query, [h.document for h in head])
540
+ except Exception as e: # noqa: BLE001 — reranker optional; degrade to fused order
541
+ print(f"[crib] reranker disabled: {e}", file=sys.stderr)
542
+ return hits
543
+ from .retrieve import reciprocal_rank_fusion
544
+
545
+ rerank_order = [head[i].id for i in sorted(
546
+ range(len(head)), key=lambda i: scores[i], reverse=True)]
547
+ fused_order = [h.id for h in hits] # existing dense⊕BM25 RRF order
548
+ new_order = reciprocal_rank_fusion(
549
+ [fused_order, rerank_order], k=self.config.retrieve.rrf_k)
550
+ by_id = {h.id: h for h in hits}
551
+ return [by_id[i] for i in new_order]
552
+
553
+ def _retrieve(self, proj: str, query: str, vec: list[float], topn: int,
554
+ hybrid: bool, rerank: bool,
555
+ keyword_labels: tuple[str, ...] = (),
556
+ keyword_weight: float = 1.0,
557
+ summary_labels: tuple[str, ...] = (),
558
+ summary_weight: float = 0.3) -> list[Hit]:
559
+ """Candidate Hits in rank order: dense retrieval, optionally RRF-fused with
560
+ a BM25 (keyword_index) ranking and/or a summary_index alias-vector ranking,
561
+ then optionally cross-encoder reranked.
562
+
563
+ Three independent recall signals, fused by rank: dense cosine (paraphrase),
564
+ BM25 (exact terms + keyword_index), and summary aliases (dense match on LLM
565
+ rephrasings — bridges the pure-paraphrase gap the section body misses).
566
+ Hits keep the cosine in `.score`; an id absent from the dense finalists has
567
+ its cosine filled by re-embedding just that handful."""
568
+ from .retrieve import reciprocal_rank_fusion, tokenize
569
+
570
+ where = {"project": proj}
571
+ dense = self.store.query(vec, k=topn, where=where)
572
+ rankings: list[list[str]] = [[h.id for h in dense]]
573
+ weights: list[float] = [1.0] # dense list votes at full weight
574
+ docs: dict = {}
575
+
576
+ if hybrid:
577
+ ids, docs, bm25 = self.index.lexical.get(
578
+ proj, keyword_labels, keyword_weight)
579
+ if ids:
580
+ sparse = bm25.scores(tokenize(query))
581
+ rankings.append([ids[j] for j in sorted(
582
+ range(len(ids)), key=lambda j: sparse[j], reverse=True)
583
+ if sparse[j] > 0][:topn])
584
+ weights.append(1.0) # BM25 (keyword downweight is in-corpus)
585
+
586
+ if summary_labels:
587
+ summary_ranked = self.index.summaries.ranking(
588
+ proj, summary_labels, vec, topn)
589
+ if summary_ranked:
590
+ rankings.append(summary_ranked)
591
+ weights.append(summary_weight) # broad aliases vote below primaries
592
+
593
+ if len(rankings) == 1: # dense only
594
+ out = dense
595
+ else:
596
+ fused = reciprocal_rank_fusion(
597
+ rankings, k=self.config.retrieve.rrf_k, weights=weights)[:topn]
598
+ dense_by = {h.id: h for h in dense}
599
+ if not docs: # need doc text to fill non-dense cosines
600
+ docs = {i: (d, m) for i, (d, m)
601
+ in self.store.get_docs(where).items()
602
+ if not (m or {}).get("alias")}
603
+ missing = [cid for cid in fused if cid not in dense_by and cid in docs]
604
+ cos: dict[str, float] = {}
605
+ if missing:
606
+ for cid, dv in zip(missing, self.embedder.embed(
607
+ [docs[cid][0] for cid in missing])):
608
+ cos[cid] = sum(a * b for a, b in zip(vec, dv)) # L2-normalized
609
+ out = []
610
+ for cid in fused:
611
+ if cid in dense_by:
612
+ out.append(dense_by[cid])
613
+ elif cid in docs:
614
+ doc, meta = docs[cid]
615
+ out.append(Hit(cid, doc, meta, round(cos.get(cid, 0.0), 4)))
616
+ if rerank:
617
+ out = self._rerank(query, out)
618
+ return out
619
+
620
+ def lookup(self, query: str, project: str | None = None, k: int = 8,
621
+ tags: list[str] | None = None, dedupe: str = "section",
622
+ min_score: float = 0.0, cwd: Path | None = None,
623
+ hybrid: bool | None = None, rerank: bool | None = None,
624
+ keyword_labels: list[str] | None = None,
625
+ keyword_weight: float | None = None,
626
+ summary_labels: list[str] | None = None,
627
+ summary_weight: float | None = None
628
+ ) -> list[LookupHit]:
629
+ """Ranked sections matching `query`.
630
+
631
+ `dedupe` collapses duplicates: "section" (default) keeps one hit per
632
+ distinct heading — so a note's several relevant sections all surface, and
633
+ only repeated windows of the *same* section merge; "file" keeps one hit
634
+ per note (breadth across notes, hides a note's other sections); "none"
635
+ keeps every chunk. Section is right for retrieval; file suits a
636
+ what-notes-are-relevant overview.
637
+ """
638
+ proj = self.resolve_project(project, cwd)
639
+ vec = self.embedder.embed_query([query])[0]
640
+ use_hybrid = self.config.retrieve.hybrid if hybrid is None else hybrid
641
+ use_rerank = self.config.retrieve.rerank if rerank is None else rerank
642
+ kw_labels = tuple(self.config.retrieve.keyword_labels
643
+ if keyword_labels is None else keyword_labels)
644
+ kw_weight = (self.config.retrieve.keyword_weight
645
+ if keyword_weight is None else keyword_weight)
646
+ sum_labels = tuple(self.config.retrieve.summary_labels
647
+ if summary_labels is None else summary_labels)
648
+ sum_weight = (self.config.retrieve.summary_weight
649
+ if summary_weight is None else summary_weight)
650
+ # Hybrid pulls a wider candidate pool so BM25 can promote terms dense ranked low.
651
+ topn = max(k * 3, 30) if use_hybrid else (k if dedupe == "none" else k * 3)
652
+ raw = self._retrieve(proj, query, vec, topn, use_hybrid, use_rerank,
653
+ kw_labels, kw_weight, sum_labels, sum_weight)
654
+ hits, seen = [], set()
655
+ line_maps: dict[str, dict[str, tuple[int, int]]] = {}
656
+ for h in raw:
657
+ if h.score <= min_score: # drop orthogonal / irrelevant matches
658
+ continue
659
+ if tags and not (set(tags) & set(
660
+ filter(None, (h.metadata.get("tags") or "").split(",")))):
661
+ continue
662
+ rp = h.metadata.get("relpath", "")
663
+ heading = h.metadata.get("heading_path", "")
664
+ key = rp if dedupe == "file" else (rp, heading)
665
+ if dedupe != "none" and key in seen:
666
+ continue
667
+ seen.add(key)
668
+ if rp not in line_maps: # read each file once, current on disk
669
+ try:
670
+ line_maps[rp] = section_line_map(self.abspath(proj, rp).read_text())
671
+ except OSError:
672
+ line_maps[rp] = {}
673
+ span = line_maps[rp].get(heading)
674
+ hits.append(LookupHit(
675
+ project=proj, relpath=rp,
676
+ heading=heading,
677
+ title=h.metadata.get("title", ""),
678
+ snippet=h.document[:280],
679
+ score=round(h.score, 4),
680
+ line_start=span[0] if span else None,
681
+ line_end=span[1] if span else None,
682
+ ))
683
+ if len(hits) >= k:
684
+ break
685
+ return hits
686
+
687
+ def apropos(self, query: str, project: str | None = None, k: int = 8,
688
+ tags: list[str] | None = None,
689
+ cwd: Path | None = None) -> list[dict[str, Any]]:
690
+ """`lookup` (same section-level dedupe) but each hit carries the FULL
691
+ matching section markdown — sliced from the file by its line span — rather
692
+ than a 280-char snippet, for rendering the matches for a human to read."""
693
+ proj = self.resolve_project(project, cwd)
694
+ out: list[dict[str, Any]] = []
695
+ for h in self.lookup(query, project, k, tags, dedupe="section", cwd=cwd):
696
+ section = h.snippet
697
+ if h.line_start and h.line_end:
698
+ try:
699
+ lines = self.abspath(proj, h.relpath).read_text().splitlines()
700
+ section = "\n".join(lines[h.line_start - 1:h.line_end])
701
+ except OSError:
702
+ pass
703
+ out.append({**vars(h), "section": section})
704
+ return out
705
+
706
+ # --- generation: distill + elaborate (knowledge-capture §2/§4, §3.1) ---
707
+ async def distill(self, relpath: str, project: str | None = None,
708
+ cwd: Path | None = None) -> dict[str, Any]:
709
+ """Revise a note in place via the LLM: compress, dedupe, normalize; keep
710
+ facts/decisions, drop deliberation, preserve code verbatim. Thrash-guarded
711
+ (no write if the body is unchanged), marked `source: distilled`, written
712
+ through the version ring so a bad revision is a cheap rollback."""
713
+ proj = self.resolve_project(project, cwd)
714
+ path = self.abspath(proj, relpath)
715
+ if not path.exists():
716
+ raise ValueError(f"no such note: {relpath} in project {proj!r}")
717
+ note = notes.load(path)
718
+ prompt = self.project_config(proj).distill_prompt or DEFAULT_DISTILL_PROMPT
719
+ from .generate import agenerate
720
+ new_body = (await agenerate(
721
+ self.config.generate, prompt, note.body, purpose="distill",
722
+ timeout=self.config.generate.timeout)).strip()
723
+ if not new_body or new_body == note.body.strip():
724
+ return {"project": proj, "relpath": relpath, "changed": False}
725
+ note.frontmatter["source"] = "distilled"
726
+ note.body = new_body
727
+ res = await self._write_note(proj, relpath, note)
728
+ return {"project": proj, "relpath": relpath, "changed": True,
729
+ "indexed": res.upserted}
730
+
731
+ async def elaborate(self, label: str, relpath: str | None = None,
732
+ project: str | None = None, cwd: Path | None = None,
733
+ overwrite: bool = False) -> dict[str, Any]:
734
+ """keyword_index: generate search terms per section for BM25 (§3.1).
735
+ `crib elaborate <label>`; activate via `[retrieve].keyword_labels`."""
736
+ from .section_index import KEYWORD_PROMPTS, resolve_prompt
737
+ prompt = resolve_prompt(label, self.config.elaborate, KEYWORD_PROMPTS)
738
+ return await self._generate_index(
739
+ "keyword_index", "elaborate", label, prompt, relpath, project, cwd,
740
+ overwrite)
741
+
742
+ async def summarize(self, label: str, relpath: str | None = None,
743
+ project: str | None = None, cwd: Path | None = None,
744
+ overwrite: bool = False) -> dict[str, Any]:
745
+ """summary_index: generate LLM rephrasings per section, embedded as dense
746
+ alias vectors (§3). `crib summarize <label>`; activate via
747
+ `[retrieve].summary_labels`."""
748
+ from .section_index import SUMMARY_PROMPTS, resolve_prompt
749
+ prompt = resolve_prompt(label, self.config.summarize, SUMMARY_PROMPTS)
750
+ return await self._generate_index(
751
+ "summary_index", "summarize", label, prompt, relpath, project, cwd,
752
+ overwrite)
753
+
754
+ # --- code symbol index (docs/code-symbol-index.md) --------------------
755
+ async def code_index(self, path: str, project: str | None = None,
756
+ cwd: Path | None = None,
757
+ patch_edges: bool = True) -> dict[str, Any]:
758
+ """Delegate to the CodeIndexer pipeline (crib/codeindexer.py)."""
759
+ return await self.indexer.code_index(path, project, cwd, patch_edges)
760
+
761
+ def _index_code_file_tracked(self, root: Path, rel: str, proj: str,
762
+ patch_edges: bool,
763
+ existing: dict[str, dict] | None = None,
764
+ describe_mode: str = "inline") -> dict[str, Any]:
765
+ """Delegate to the CodeIndexer pipeline (kept on Crib so the watcher and the
766
+ resident-cache revalidate hook call it unchanged)."""
767
+ return self.indexer._index_code_file_tracked(root, rel, proj, patch_edges,
768
+ existing, describe_mode)
769
+
770
+ # ── Resident code cache: delegates to CodeStore (crib/codestore.py) ────────
771
+ # Thin delegators so existing call sites are untouched; the state + its
772
+ # invariants live in `self.code`. `_code_watched` (watcher, not code state) and
773
+ # `_revalidate`/`_drop_file` (pipeline-coupled) stay as real methods below.
774
+ def _code_lock(self, proj: str) -> threading.Lock:
775
+ return self.code.lock(proj)
776
+
777
+ def _bump_code_epoch(self, proj: str) -> None:
778
+ self.code.bump_epoch(proj)
779
+
780
+ def _code_freshness(self) -> str:
781
+ return self.code.freshness()
782
+
783
+ def _code_watched(self, proj: str) -> bool:
784
+ """True when the code watcher is live-watching this project's source, so a
785
+ per-query source revalidation sweep is redundant (edits refresh on save)."""
786
+ cw = self._code_watcher
787
+ return cw is not None and cw.watches(proj)
788
+
789
+ def _dir_sig(self, proj: str) -> tuple[int, int]:
790
+ return self.code.dir_sig(proj)
791
+
792
+ def _code_tok(self, proj: str) -> tuple[str, Any]:
793
+ return self.code.tok(proj)
794
+
795
+ def _resident_code(self, proj: str) -> _ResidentCode:
796
+ return self.code.resident(proj, revalidate=self._revalidate,
797
+ watched=self._code_watched(proj))
798
+
799
+ def _reload_code(self, proj: str, tok: Any,
800
+ prev: _ResidentCode | None) -> _ResidentCode:
801
+ return self.code.reload(proj, tok, prev)
802
+
803
+ def code_indexed_projects(self) -> list[dict[str, Any]]:
804
+ """Projects that have a symbol_index, with counts — for orienting an agent
805
+ whose call resolved to the wrong/empty project."""
806
+ from .codeindex import SymbolIndex
807
+ out = []
808
+ for name in self.projects():
809
+ si = SymbolIndex(self.paths.project_dir(name))
810
+ if si.is_populated():
811
+ out.append({"project": name, "symbols": len(si.all())})
812
+ return sorted(out, key=lambda x: -x["symbols"])
813
+
814
+ def _revalidate(self, proj: str) -> None:
815
+ """Delegate to CodeStore, injecting the pipeline's per-file indexer."""
816
+ self.code.revalidate(proj, self._index_code_file_tracked)
817
+
818
+ def _drop_file(self, proj: str, relpath: str) -> None:
819
+ self.code.drop_file(proj, relpath)
820
+
821
+ def _require_code_index(self, proj: str) -> None:
822
+ """Raise a self-diagnosing error when `proj` has no code index — so a call
823
+ that resolved to the wrong/unset project tells the agent how to fix it (set the
824
+ project, or name a different one; which projects ARE indexed) rather than
825
+ returning a bare `[]` it misreads as 'this codebase isn't indexed'."""
826
+ from .codeindex import SymbolIndex
827
+ if SymbolIndex(self.paths.project_dir(proj)).is_populated():
828
+ return
829
+ avail = self.code_indexed_projects()
830
+ if avail:
831
+ names = ", ".join(f"{p['project']}" for p in avail)
832
+ hint = (f" If you meant a DIFFERENT, already-indexed project ({names}), name "
833
+ f"it: pass project=<name> or project_path=<a path in that repo> (or "
834
+ f"use_project <name> to switch your current project).")
835
+ else:
836
+ hint = ""
837
+ raise ValueError(
838
+ f"project {proj!r} isn't code-indexed yet. If this is the repo you're working "
839
+ f"in, INDEX IT NOW then retry: run project_index (project_path=<this repo dir>) "
840
+ f"— it indexes the source so lookup/dossier/xref work; do NOT grep or read "
841
+ f"files instead.{hint}")
842
+
843
+ # ── Whole-project lifecycle: setup / index / forget / status ──────────────
844
+ # Shared engine behind `crib project <verb>` (superset) and the code/notes
845
+ # facets. Everything defers to `_ensure_crib`, the sensible-default .crib
846
+ # creator, so onboarding an unfamiliar repo is one call (docs §…).
847
+ def _code_ignore(self) -> frozenset[str]:
848
+ return frozenset({".git", "node_modules", ".venv", "venv", "__pycache__",
849
+ ".mypy_cache", ".pytest_cache", ".ruff_cache", "dist",
850
+ "build", "target", ".tox", ".idea", "site-packages",
851
+ ".cache", ".claude", ".DS_Store"})
852
+
853
+ def _detect_code_globs(self, root: Path) -> list[str]:
854
+ """Auto-detect source globs: which LSP-supported extensions actually occur
855
+ under `root` (junk dirs pruned) → `**/*.<ext>` per present type."""
856
+ from .codeindex import load_specs
857
+ exts = {e for spec in load_specs().values() if isinstance(spec, dict)
858
+ for e in (spec.get("extensionToLanguage") or {})}
859
+ junk, present = self._code_ignore(), set()
860
+ for dp, dirs, files in os.walk(root):
861
+ dirs[:] = [d for d in dirs if d not in junk and not d.startswith(".")]
862
+ for fn in files:
863
+ if (e := Path(fn).suffix.lower()) in exts:
864
+ present.add(e)
865
+ return sorted(f"**/*{e}" for e in present)
866
+
867
+ def _nested_project_roots(self, root: Path) -> list[Path]:
868
+ """Directories under `root` carrying their OWN `.crib` — project
869
+ BOUNDARIES: their code belongs to the nested project (a vendored
870
+ submodule with a .crib, say), never to the parent's index. `refs:` is
871
+ how the parent xrefs into it (cross-project refs)."""
872
+ junk = self._code_ignore()
873
+ out: list[Path] = []
874
+ for dp, dirs, files in os.walk(root):
875
+ dirs[:] = [d for d in dirs if d not in junk and not d.startswith(".")]
876
+ if ".crib" in files and Path(dp) != root:
877
+ out.append(Path(dp))
878
+ dirs[:] = [] # bounded — no need to descend
879
+ return out
880
+
881
+ def _enumerate_code_files(self, root: Path, globs: list[str]) -> list[Path]:
882
+ """Files to index: the extension globs, PLUS extensionless/unknown-suffix
883
+ files whose grammar (shebang / bare name / `#compdef`|`#autoload` marker)
884
+ resolves to a language served by an installed LSP — so shell autoload
885
+ functions and dotfiles get indexed, not just `*.zsh`. Subtrees with
886
+ their own `.crib` are skipped (project boundaries)."""
887
+ from .codeindex import content_lang, load_grammar, load_specs, resolve_command
888
+ junk, seen = self._code_ignore(), set()
889
+ bounds = self._nested_project_roots(root)
890
+ for g in globs:
891
+ for p in root.glob(g):
892
+ if p.is_file() and not any(part in junk
893
+ for part in p.relative_to(root).parts) \
894
+ and not any(p.is_relative_to(b) for b in bounds):
895
+ seen.add(p)
896
+ # extensionless / unknown-extension files matched by the grammar map
897
+ specs = load_specs()
898
+ served: set[str] = set()
899
+ for sp in specs.values():
900
+ if isinstance(sp, dict) and resolve_command(sp):
901
+ served.update((sp.get("extensionToLanguage") or {}).values())
902
+ known_exts = {e for sp in specs.values() if isinstance(sp, dict)
903
+ for e in (sp.get("extensionToLanguage") or {})}
904
+ grammar = load_grammar()
905
+ for dp, dirs, files in os.walk(root):
906
+ dirs[:] = [d for d in dirs if d not in junk and not d.startswith(".")
907
+ and not any((Path(dp) / d) == b for b in bounds)]
908
+ for fn in files:
909
+ p = Path(dp) / fn
910
+ if p in seen or p.suffix.lower() in known_exts:
911
+ continue # globs already cover known extensions
912
+ lang = content_lang(p, grammar)
913
+ if lang and lang in served:
914
+ seen.add(p)
915
+ return sorted(seen)
916
+
917
+ def _ensure_crib(self, cwd: Path | None, project: str | None,
918
+ want_code: bool, want_docs: bool) -> tuple[Any, bool]:
919
+ """Find the repo's `.crib`, or CREATE one with sensible defaults (project =
920
+ repo dir name; auto-detected code `paths:` + doc `docs:` globs indexed
921
+ in-situ). Returns (CribLink, created?). The one primitive both `project
922
+ setup` and the facet setups defer to."""
923
+ base = (Path(cwd) if cwd else Path.cwd()).resolve()
924
+ link = CribLink.find(base)
925
+ if link and link.root:
926
+ return link, False
927
+ # Anchor at the nearest repo marker, else at `base` ITSELF — never escape to
928
+ # base.parent (find_root's no-marker fallback), which would write .crib in the
929
+ # wrong dir and index the parent tree.
930
+ root = base
931
+ for d in (base, *base.parents):
932
+ if (d / ".git").exists() or (d / "pyproject.toml").exists() \
933
+ or (d / "setup.py").exists():
934
+ root = d
935
+ break
936
+ name = project or root.name.replace(" ", "-")
937
+ lines = ["# Auto-created by `crib project setup` — ties this repo to a crib "
938
+ "project.", f"project: {name}"]
939
+ # globs are quoted: a bare `- **/*.py` makes YAML read `*` as an alias anchor.
940
+ if want_code and (globs := self._detect_code_globs(root)):
941
+ lines += ["paths:", *[f' - "{g}"' for g in globs]]
942
+ if want_docs:
943
+ lines += ["docs:", ' - "README.md"', ' - "docs/**/*.md"']
944
+ (root / ".crib").write_text("\n".join(lines) + "\n")
945
+ return CribLink.find(root), True
946
+
947
+ async def _index_project_code(self, proj: str, root: Path,
948
+ globs: list[str]) -> dict[str, Any]:
949
+ """Delegate to the CodeIndexer pipeline (project setup/index call this)."""
950
+ return await self.indexer._index_project_code(proj, root, globs)
951
+
952
+ async def project_setup(self, project: str | None = None,
953
+ cwd: Path | None = None) -> dict[str, Any]:
954
+ """Onboard a repo end-to-end: ensure `.crib` (create with sensible defaults if
955
+ missing), index its declared docs IN-SITU (source is master, never copied),
956
+ AND index all its source code. The superset — `code`+`notes`. Idempotent;
957
+ safe to re-run. (`import` — copy a file into memory — stays a separate,
958
+ explicit verb.)"""
959
+ link, created = self._ensure_crib(cwd, project, want_code=True, want_docs=True)
960
+ proj = project or link.project
961
+ docs = await self.index_docs_insitu(proj, cwd) if link.doc_patterns else {}
962
+ globs = link.paths or self._detect_code_globs(link.root)
963
+ code = await self._index_project_code(proj, link.root, globs)
964
+ return {"project": proj, "root": str(link.root), "crib_created": created,
965
+ "docs_indexed": docs.get("docs", 0), **code}
966
+
967
+ async def project_index(self, project: str | None = None,
968
+ cwd: Path | None = None) -> dict[str, Any]:
969
+ """(Re)index the project's SOURCE CODE and in-situ docs from its `.crib`
970
+ (ensuring a `.crib` first). Cheap re-run via the content-hash gate."""
971
+ link, created = self._ensure_crib(cwd, project, want_code=True, want_docs=False)
972
+ proj = project or link.project
973
+ docs = await self.index_docs_insitu(proj, cwd) if link.doc_patterns else {}
974
+ globs = link.paths or self._detect_code_globs(link.root)
975
+ code = await self._index_project_code(proj, link.root, globs)
976
+ return {"project": proj, "root": str(link.root), "crib_created": created,
977
+ "docs_indexed": docs.get("docs", 0), **code}
978
+
979
+ def _project_refs(self, proj: str) -> list[dict[str, Any]]:
980
+ """Delegate to Refs (crib/refs.py) — a project's `.crib` refs: targets."""
981
+ return self.refs.project_refs(proj)
982
+
983
+ def project_status(self, project: str | None = None,
984
+ cwd: Path | None = None) -> dict[str, Any]:
985
+ """Is this project code-indexed? symbol/file counts, kind breakdown, the
986
+ `.crib` source paths — for orienting before setup/index."""
987
+ from collections import Counter
988
+
989
+ from .codeindex import SymbolIndex
990
+ proj = self.resolve_project(project, cwd)
991
+ si = SymbolIndex(self.paths.project_dir(proj))
992
+ entries = si.all()
993
+ link = CribLink.find(Path(cwd)) if cwd else None
994
+ srcs = self._source_roots(proj).all()
995
+ doc_count = sum(1 for m in self.store.get_meta({"project": proj}).values()
996
+ if m.get("relpath", "").startswith(SRC_PREFIX))
997
+ refs = [{**r, "root": str(r["root"]) if r["root"] else None}
998
+ for r in self._project_refs(proj)]
999
+ return {"project": proj, "indexed": si.is_populated(),
1000
+ "symbols": len(entries),
1001
+ "files": len({e.get("file") for e in entries}),
1002
+ "kinds": dict(Counter(e.get("kind", "?") for e in entries)),
1003
+ "paths": (link.paths if link else []),
1004
+ "refs": refs,
1005
+ "doc_sources": srcs, "doc_chunks": doc_count,
1006
+ "crib": (str(link.root / ".crib") if link and link.root else None)}
1007
+
1008
+ def status(self) -> dict[str, Any]:
1009
+ """One-call health summary (the `status` CLI verb / MCP tool): every
1010
+ project's inventory (notes, in-situ doc chunks, code symbols, learnings),
1011
+ git-sync state, the warm LSP sessions (which servers are attached,
1012
+ alive/busy/idle), and any in-flight indexing. Counts are cheap file
1013
+ counts (1 toml = 1 symbol), never full parses."""
1014
+ from .codeindex import _POOL, LEARNINGS_DIR
1015
+ projects = []
1016
+ for name in self.projects():
1017
+ nd = self.paths.notes_dir(name)
1018
+ ld = nd / LEARNINGS_DIR
1019
+ sd = self.paths.project_dir(name) / "symbol_index"
1020
+ doc_chunks = sum(1 for m in self.store.get_meta({"project": name}).values()
1021
+ if m.get("relpath", "").startswith(SRC_PREFIX))
1022
+ projects.append({
1023
+ "project": name,
1024
+ "notes": sum(1 for _ in nd.rglob("*.md")) if nd.exists() else 0,
1025
+ "learnings": sum(1 for _ in ld.glob("*.md")) if ld.exists() else 0,
1026
+ "symbols": sum(1 for _ in sd.glob("*.toml")) if sd.exists() else 0,
1027
+ "doc_chunks": doc_chunks,
1028
+ })
1029
+ with self.code.indexing_lock:
1030
+ indexing = {p: list(v) for p, v in self.code.indexing.items() if v}
1031
+ sweeps = {p: dict(v) for p, v in self.code.sweeps.items()}
1032
+ return {"projects": projects,
1033
+ "git": self.git.state(),
1034
+ "lsp_sessions": _POOL.stats(),
1035
+ "indexing": indexing,
1036
+ # sweep progress: {proj: {done, total}} while a project index runs,
1037
+ # ABSENT when finished — poll this to wait on a background index
1038
+ "sweeps": sweeps,
1039
+ "store": type(self.store).__name__,
1040
+ "embed_model": self.config.embed.model}
1041
+
1042
+ def project_forget(self, project: str | None = None, cwd: Path | None = None,
1043
+ with_learnings: bool = False) -> dict[str, Any]:
1044
+ """Clear the project's code index (the symbol_index). KEEPS attached learnings,
1045
+ notes and `.crib` by default — learnings are durable human source-of-truth;
1046
+ pass with_learnings=True to drop those too."""
1047
+ import shutil
1048
+
1049
+ from .codeindex import LEARNINGS_DIR, SymbolIndex
1050
+ proj = self.resolve_project(project, cwd)
1051
+ si_root = SymbolIndex(self.paths.project_dir(proj)).root
1052
+ removed = len(list(si_root.glob("*.toml"))) if si_root.exists() else 0
1053
+ if si_root.exists():
1054
+ shutil.rmtree(si_root)
1055
+ self.code.cache.pop(proj, None) # drop resident cache (trust-mode won't see rmtree)
1056
+ self._bump_code_epoch(proj)
1057
+ # In-situ docs are index-only (source is master) — drop their chunks + the
1058
+ # source registry too; re-runnable via `project index`.
1059
+ reg = self._source_roots(proj)
1060
+ doc_ids = [i for i, m in self.store.get_meta({"project": proj}).items()
1061
+ if m.get("relpath", "").startswith(SRC_PREFIX)]
1062
+ if doc_ids:
1063
+ self.store.delete(doc_ids)
1064
+ self.index.invalidate_caches(proj)
1065
+ for prefix in list(reg.all()):
1066
+ reg.remove(prefix)
1067
+ learnings = 0
1068
+ if with_learnings:
1069
+ ldir = self.notes_dir(proj) / LEARNINGS_DIR
1070
+ if ldir.exists():
1071
+ learnings = len(list(ldir.glob("*.md")))
1072
+ shutil.rmtree(ldir)
1073
+ return {"project": proj, "symbols_removed": removed,
1074
+ "doc_chunks_removed": len(doc_ids), "learnings_removed": learnings}
1075
+
1076
+ def code_xref(self, symbol: str, project: str | None = None,
1077
+ cwd: Path | None = None) -> list[dict[str, Any]]:
1078
+ """A symbol's callers/callees/references from the persisted symbol_index."""
1079
+ return self.query.xref(self.resolve_project(project, cwd), symbol)
1080
+
1081
+ def code_dossier(self, symbol: str, project: str | None = None,
1082
+ cwd: Path | None = None, edge_cap: int = 20) -> dict[str, Any]:
1083
+ """Everything about one symbol (+ neighbour descriptions + any learning)."""
1084
+ return self.query.dossier(self.resolve_project(project, cwd), symbol, edge_cap)
1085
+
1086
+ # ── Durable learnings: delegate to Learnings (crib/learnings.py) ───────────
1087
+ # Public code_* wrappers resolve_project then delegate; the internal helpers
1088
+ # (_attach_learnings / _learning_relpath / _learning_fqns / _rehome_candidates,
1089
+ # called by the code query methods) delegate directly.
1090
+ async def code_append(self, symbol: str, text: str, project: str | None = None,
1091
+ cwd: Path | None = None) -> dict[str, Any]:
1092
+ """Attach a durable learning to a code symbol (append a dated entry)."""
1093
+ return await self.learnings.append(self.resolve_project(project, cwd), symbol, text)
1094
+
1095
+ async def code_edit(self, symbol: str, new_content: str, project: str | None = None,
1096
+ cwd: Path | None = None) -> dict[str, Any]:
1097
+ """Rewrite a symbol's learning body wholesale."""
1098
+ return await self.learnings.edit(self.resolve_project(project, cwd), symbol, new_content)
1099
+
1100
+ async def code_forget(self, symbol: str, project: str | None = None,
1101
+ cwd: Path | None = None) -> dict[str, Any]:
1102
+ """Remove a symbol's learning (recoverable; works on orphans)."""
1103
+ return await self.learnings.forget(self.resolve_project(project, cwd), symbol)
1104
+
1105
+ async def code_reaffirm(self, symbol: str, project: str | None = None,
1106
+ cwd: Path | None = None) -> dict[str, Any]:
1107
+ """Clear a learning's stale flag without rewriting it."""
1108
+ return await self.learnings.reaffirm(self.resolve_project(project, cwd), symbol)
1109
+
1110
+ def code_learnings(self, project: str | None = None, cwd: Path | None = None,
1111
+ orphans_only: bool = False) -> list[dict[str, Any]]:
1112
+ """Health report for attached learnings (ok/moved/orphan)."""
1113
+ return self.learnings.report(self.resolve_project(project, cwd), orphans_only)
1114
+
1115
+ async def code_rehome(self, old_fqn: str, new_fqn: str | None = None,
1116
+ project: str | None = None,
1117
+ cwd: Path | None = None) -> dict[str, Any]:
1118
+ """Re-point an orphaned learning (no target = ranked suggestions)."""
1119
+ return await self.learnings.rehome(self.resolve_project(project, cwd), old_fqn, new_fqn)
1120
+
1121
+ def code_read(self, symbol: str, project: str | None = None,
1122
+ cwd: Path | None = None) -> dict[str, Any]:
1123
+ """Read a symbol's learning note (frontmatter + body)."""
1124
+ return self.learnings.read(self.resolve_project(project, cwd), symbol)
1125
+
1126
+ def code_lookup(self, query: str, project: str | None = None, k: int = 8,
1127
+ cwd: Path | None = None,
1128
+ sparse_weight: float = 0.2) -> list[dict[str, Any]]:
1129
+ """Find a code symbol by concept OR name (hybrid dense+kw), fanning out to refs."""
1130
+ return self.query.lookup(self.resolve_project(project, cwd), query, k, sparse_weight)
1131
+
1132
+ def code_graph(self, symbol: str, direction: str = "callees", depth: int = 6,
1133
+ project: str | None = None,
1134
+ cwd: Path | None = None) -> dict[str, Any]:
1135
+ """pstree-style call graph around a symbol (recursive), crossing into refs."""
1136
+ return self.query.graph(self.resolve_project(project, cwd), symbol, direction, depth)
1137
+
1138
+ async def _generate_index(self, root_name: str, purpose: str, label: str,
1139
+ prompt: str | None, relpath: str | None,
1140
+ project: str | None, cwd: Path | None,
1141
+ overwrite: bool) -> dict[str, Any]:
1142
+ """Shared section-level generation for keyword_index / summary_index: one
1143
+ LLM call per **section** with the full section as context (windows share
1144
+ one result), persisted section-addressed so it survives re-windowing.
1145
+ Bounded-concurrent, per-call timeout, error-isolated; skips cached sections
1146
+ unless `overwrite`. Off the write path."""
1147
+ proj = self.resolve_project(project, cwd)
1148
+ from .section_index import SectionIndex, parse_terms
1149
+ if prompt is None:
1150
+ raise ValueError(
1151
+ f"unknown {purpose} label {label!r}: no builtin and no "
1152
+ f"[{purpose}.{label}].prompt in config")
1153
+ store = SectionIndex(self.paths.project_dir(proj), root_name)
1154
+ from .generate import agenerate, agenerate_structured, resolve_provider
1155
+ try: # record the resolved provider for provenance
1156
+ _p = resolve_provider(self.config.generate, purpose)
1157
+ model = _p.model or _p.adapter or ""
1158
+ except Exception: # noqa: BLE001 — provenance only; generation reports real errors
1159
+ model = self.config.generate.model or self.config.generate.adapter
1160
+
1161
+ # Group chunks into their sections (section_hash), reconstructing the full
1162
+ # section text once per note from the file (windows would double-count
1163
+ # overlap). One generation per section.
1164
+ section_line_maps: dict[str, dict[str, tuple[int, int]]] = {}
1165
+
1166
+ def _section_text(rp: str, heading: str, fallback: str) -> str:
1167
+ if rp not in section_line_maps:
1168
+ try:
1169
+ section_line_maps[rp] = section_line_map(
1170
+ self.abspath(proj, rp).read_text())
1171
+ except OSError:
1172
+ section_line_maps[rp] = {}
1173
+ span = section_line_maps[rp].get(heading)
1174
+ if span:
1175
+ try:
1176
+ lines = self.abspath(proj, rp).read_text().splitlines()
1177
+ return "\n".join(lines[span[0] - 1:span[1]])
1178
+ except OSError:
1179
+ pass
1180
+ return fallback
1181
+
1182
+ seen_sections: set[str] = set()
1183
+ targets: list[tuple[str, str, str, str]] = [] # (section_hash, relpath, heading, body)
1184
+ skipped = 0
1185
+ for _cid, (doc, meta) in self.store.get_docs({"project": proj}).items():
1186
+ if relpath and meta.get("relpath") != relpath:
1187
+ continue
1188
+ sh = meta.get("section_hash") or meta.get("content_hash")
1189
+ if not sh or sh in seen_sections:
1190
+ continue
1191
+ seen_sections.add(sh)
1192
+ if not overwrite and store.has(label, sh):
1193
+ skipped += 1
1194
+ continue
1195
+ rp = meta.get("relpath", "")
1196
+ heading = meta.get("heading_path", "")
1197
+ targets.append((sh, rp, heading, _section_text(rp, heading, doc)))
1198
+
1199
+ gen = self.config.generate
1200
+ total = len(targets)
1201
+ sem = asyncio.Semaphore(max(1, gen.concurrency))
1202
+ counters = {"written": 0, "errors": 0, "done": 0, "bulk_docs": 0}
1203
+ done_sh: set[str] = set() # sections already written (bulk covered them)
1204
+ tag = f"{purpose} {label}"
1205
+ mode = "bulk+mop-up" if gen.bulk else "per-section"
1206
+ print(f"[{tag}] {total} sections to generate ({mode}; skipped {skipped} "
1207
+ f"cached; concurrency {gen.concurrency}, timeout {gen.timeout:g}s)",
1208
+ file=sys.stderr, flush=True)
1209
+
1210
+ def _record(sh: str, rp: str, heading: str, terms: list[str]) -> None:
1211
+ if terms and sh not in done_sh:
1212
+ store.write(label, sh, terms, relpath=rp, heading=heading, model=model)
1213
+ counters["written"] += 1
1214
+ done_sh.add(sh)
1215
+
1216
+ async def _one(sh: str, rp: str, heading: str, body: str) -> None:
1217
+ # Per-section: bounded-concurrent, timeout-capped, error-isolated —
1218
+ # one hung/failed section is skipped, never sinks the pass. Serves both
1219
+ # the legacy path (bulk off) and the mop-up for bulk-missed sections.
1220
+ user = f"{heading}\n\n{body}" if heading else body
1221
+ try:
1222
+ async with sem:
1223
+ text = await agenerate(gen, prompt, user, purpose=purpose,
1224
+ timeout=gen.timeout)
1225
+ terms = parse_terms(text)
1226
+ except Exception as e: # noqa: BLE001 — timeout or generation failure
1227
+ counters["errors"] += 1
1228
+ counters["done"] += 1
1229
+ print(f"[{tag}] {counters['done']}/{total} ERR {rp} :: "
1230
+ f"{heading[-44:]} — {type(e).__name__}: {e}",
1231
+ file=sys.stderr, flush=True)
1232
+ return
1233
+ _record(sh, rp, heading, terms)
1234
+ counters["done"] += 1
1235
+ print(f"[{tag}] {counters['done']}/{total} ok {rp} :: "
1236
+ f"{heading[-44:]} ({len(terms)} terms)", file=sys.stderr, flush=True)
1237
+
1238
+ # Phase 1 — whole-doc bulk authoring (structured, one call per note/batch):
1239
+ # the model sees a note's sections together, so it can pick section-
1240
+ # *distinctive* terms (blind per-section authoring made them generic — see
1241
+ # docs/retrieval-and-adoption.md §5.5). Content-addressing makes it safe: a
1242
+ # skipped/malformed section simply isn't written and is swept by Phase 2, so
1243
+ # strict model conformance is an efficiency, not a correctness, property.
1244
+ if gen.bulk and targets:
1245
+ from collections import defaultdict
1246
+
1247
+ bulk_schema = {
1248
+ "type": "object",
1249
+ "properties": {"sections": {"type": "array", "items": {
1250
+ "type": "object",
1251
+ "properties": {"heading": {"type": "string"},
1252
+ "terms": {"type": "array",
1253
+ "items": {"type": "string"}}},
1254
+ "required": ["heading", "terms"]}}},
1255
+ "required": ["sections"],
1256
+ }
1257
+ bulk_system = (
1258
+ f"{prompt}\n\nYou are given a DOCUMENT of several sections, each "
1259
+ "introduced by a line `<<< SECTION: <heading> >>>`. Apply the "
1260
+ "instruction above to EACH section independently and return one "
1261
+ "result per section, using that section's exact <heading> string. "
1262
+ "Cover every section.")
1263
+
1264
+ def _match(h: str, by_head: dict[str, tuple]) -> tuple | None:
1265
+ t = by_head.get(h)
1266
+ if t:
1267
+ return t
1268
+ hl = h.strip().lower().lstrip("#").strip()
1269
+ for head, tt in by_head.items():
1270
+ hh = head.lower()
1271
+ if hl and (hl == hh.split("/")[-1].strip() or hl in hh):
1272
+ return tt
1273
+ return None
1274
+
1275
+ by_doc: dict[str, list[tuple]] = defaultdict(list)
1276
+ for t in targets:
1277
+ by_doc[t[1]].append(t)
1278
+ step = max(1, gen.bulk_max_sections)
1279
+ batches = [items[i:i + step] for items in by_doc.values()
1280
+ for i in range(0, len(items), step)]
1281
+
1282
+ async def _bulk(batch: list[tuple]) -> None:
1283
+ rp = batch[0][1]
1284
+ by_head = {t[2]: t for t in batch}
1285
+ user = "\n\n".join(f"<<< SECTION: {t[2]} >>>\n{t[3]}" for t in batch)
1286
+ try:
1287
+ async with sem:
1288
+ data = await agenerate_structured(
1289
+ gen, bulk_system, user, bulk_schema, purpose=purpose,
1290
+ schema_name="emit_terms",
1291
+ schema_description="Per-section search terms for the document.",
1292
+ timeout=gen.timeout)
1293
+ except Exception as e: # noqa: BLE001 — bulk fails → mop-up covers it
1294
+ print(f"[{tag}] bulk ERR {rp} ({len(batch)} sec) — "
1295
+ f"{type(e).__name__}: {e} → mop-up",
1296
+ file=sys.stderr, flush=True)
1297
+ return
1298
+ items = (data.get("sections") if isinstance(data, dict) else None) or []
1299
+ for it in items:
1300
+ if not isinstance(it, dict):
1301
+ continue
1302
+ terms = [str(x).strip() for x in (it.get("terms") or [])
1303
+ if str(x).strip()]
1304
+ tgt = _match(str(it.get("heading") or ""), by_head)
1305
+ if tgt and terms:
1306
+ _record(tgt[0], tgt[1], tgt[2], terms)
1307
+ counters["bulk_docs"] += 1
1308
+ got = sum(1 for t in batch if t[0] in done_sh)
1309
+ print(f"[{tag}] bulk {rp}: {got}/{len(batch)} sections",
1310
+ file=sys.stderr, flush=True)
1311
+
1312
+ await asyncio.gather(*(_bulk(b) for b in batches))
1313
+
1314
+ # Phase 2 — per-section mop-up for anything Phase 1 didn't cover (or every
1315
+ # section when bulk is off). Idempotent: `done_sh` gates re-writes.
1316
+ mop = [t for t in targets if t[0] not in done_sh]
1317
+ if mop:
1318
+ if gen.bulk and counters["bulk_docs"]:
1319
+ print(f"[{tag}] mop-up: {len(mop)} of {total} sections uncovered",
1320
+ file=sys.stderr, flush=True)
1321
+ await asyncio.gather(*(_one(*t) for t in mop))
1322
+
1323
+ if targets:
1324
+ self.index.invalidate_caches(proj) # feeds BM25 (keywords) + aliases (summaries)
1325
+ return {"project": proj, "label": label, "written": counters["written"],
1326
+ "skipped": skipped, "errors": counters["errors"], "total": total,
1327
+ "bulk_docs": counters["bulk_docs"]}
1328
+
1329
+ # --- in-situ docs (default: source is master, never copied) ------------
1330
+ async def index_docs_insitu(self, project: str | None = None,
1331
+ cwd: Path | None = None) -> dict[str, Any]:
1332
+ """Index a repo's `.crib`-declared docs IN-SITU — the source tree is the
1333
+ master; crib holds only the index, never a copy. Each doc is a
1334
+ source-anchored note keyed `sources/<repo>/<rel>`; `read`/`locate` return
1335
+ the repo file, so an LLM that edits it edits the master. Re-runnable
1336
+ (hash-gated), and it prunes docs deleted from the source."""
1337
+ link = CribLink.find(cwd or Path.cwd())
1338
+ if link is None or link.root is None:
1339
+ raise ValueError("no .crib found from cwd upward")
1340
+ proj = project or link.project
1341
+ repo = link.root.name
1342
+ prefix = f"{SRC_PREFIX}{repo}/"
1343
+ self._source_roots(proj).upsert(prefix, link.root)
1344
+ self._register_code_root(proj, link.root) # watch source-tree edits (docs + code)
1345
+
1346
+ nd = self.notes_dir(proj)
1347
+ seen: set[str] = set()
1348
+ indexed: list[str] = []
1349
+ for pattern in link.doc_patterns:
1350
+ for src in sorted(link.root.glob(pattern)):
1351
+ if not src.is_file():
1352
+ continue
1353
+ rel = src.relative_to(link.root)
1354
+ relpath = src_relpath(repo, rel.as_posix())
1355
+ seen.add(relpath)
1356
+ res = await self.index.index_file(proj, nd, relpath, content_path=src)
1357
+ if res.changed:
1358
+ indexed.append(relpath)
1359
+ # Prune so the `docs:` globs are AUTHORITATIVE: anything indexed under this
1360
+ # prefix that the current globs no longer match is dropped — whether it was
1361
+ # removed from the source tree OR indexed out-of-glob by the watcher (which
1362
+ # now filters by the same globs, but this cleans up ones that leaked in
1363
+ # before that). The source file, if any, stays; crib only owned the index.
1364
+ removed = 0
1365
+ for rp in self._indexed_relpaths(proj, prefix) - seen:
1366
+ removed += await self.index.forget(proj, rp)
1367
+ return {"project": proj, "root": str(link.root), "prefix": prefix,
1368
+ "docs": len(seen), "changed": len(indexed), "removed": removed}
1369
+
1370
+ def _indexed_relpaths(self, project: str, prefix: str) -> set[str]:
1371
+ """Relpaths currently indexed under `prefix` (one meta scan)."""
1372
+ out: set[str] = set()
1373
+ for m in self.store.get_meta({"project": project}).values():
1374
+ rp = m.get("relpath", "")
1375
+ if rp.startswith(prefix):
1376
+ out.add(rp)
1377
+ return out
1378
+
1379
+ # --- import ------------------------------------------------------------
1380
+ async def import_files(self, paths: list[str], project: str | None = None,
1381
+ cwd: Path | None = None) -> dict[str, Any]:
1382
+ """Copy explicit files INTO memory as crib-owned notes — manual only.
1383
+
1384
+ Unlike in-situ docs (source is master, never copied), this takes a list of
1385
+ paths you name and pulls a snapshot into `imported/<name>.md`: git-synced,
1386
+ editable in crib, versioned. Use it to *own* a copy (annotate it, carry it
1387
+ cross-machine). Source wins on re-import; the note id (and history) is
1388
+ preserved across re-pulls. Provenance is byte-identical across machines so a
1389
+ git sync never conflicts on it (DESIGN §14)."""
1390
+ proj = self.resolve_project(project, cwd)
1391
+ created = self.project_is_new(proj)
1392
+ today = datetime.date.today().isoformat()
1393
+
1394
+ imported: list[str] = []
1395
+ for p in paths:
1396
+ src = Path(p).expanduser()
1397
+ if not src.is_absolute():
1398
+ # Relative paths anchor to the CALLER: the CLI ships its shell cwd,
1399
+ # an MCP agent's `project_path` names the repo. Without an anchor,
1400
+ # error — resolving against the daemon's own cwd would be silent
1401
+ # nonsense (and could even hit an unrelated same-named file).
1402
+ if cwd is None:
1403
+ raise ValueError(
1404
+ f"relative path {p!r} has no anchor: pass absolute paths, "
1405
+ "or project_path=<repo dir> to resolve them against")
1406
+ src = cwd / src
1407
+ src = src.resolve()
1408
+ if not src.is_file():
1409
+ raise ValueError(f"not a file: {p}")
1410
+ relpath = f"imported/{src.name}"
1411
+ sfm, sbody = notes.parse(src.read_text())
1412
+ tgt = self.abspath(proj, relpath)
1413
+ prev = notes.load(tgt) if tgt.exists() else None
1414
+ fm = dict(sfm)
1415
+ fm.update({
1416
+ "source": "imported",
1417
+ "source_path": portable_path(src, self.config.locations),
1418
+ "imported": (prev.frontmatter.get("imported") if prev else None)
1419
+ or today,
1420
+ })
1421
+ note_id = (prev.id if prev and prev.id else None) or derived_ulid(relpath)
1422
+ fm = {"id": note_id, **fm}
1423
+ note = Note(path=tgt, frontmatter=fm, body=sbody)
1424
+ await self._write_note(proj, relpath, note)
1425
+ imported.append(relpath)
1426
+ return {"project": proj, "imported": len(imported), "files": imported,
1427
+ "created": created}
1428
+
1429
+ # --- claude harness memory mirror (DESIGN §13) -------------------------
1430
+ async def import_claude_memory(self, project: str | None = None,
1431
+ cwd: Path | None = None,
1432
+ root: Path | None = None) -> dict[str, Any]:
1433
+ """Mirror Claude Code's harness memory into
1434
+ `<project>/notes/claude-memory/<host>/`.
1435
+
1436
+ One-way: the harness owns those files; we copy+index, never write back.
1437
+ Host-namespaced so a git-synced data dir merges (not collides) two
1438
+ machines' memories. The crib note id is preserved across syncs, so
1439
+ history/identity survive; files removed upstream are dropped (reconcile,
1440
+ scoped to THIS host). Records a binding for the daemon's live mirror.
1441
+ """
1442
+ start = root or cwd or Path.cwd()
1443
+ src_root = root or claudemem.find_harness_root(start)
1444
+ if src_root is None or not claudemem.harness_memory_dir(src_root).is_dir():
1445
+ raise ValueError(
1446
+ f"no Claude memory dir found from {start} upward "
1447
+ f"(looked under {claudemem.claude_config_dir() / 'projects'})")
1448
+ mem_dir = claudemem.harness_memory_dir(src_root)
1449
+ proj = self.resolve_project(project, cwd)
1450
+ created = self.project_is_new(proj)
1451
+ prefix = f"claude-memory/{claudemem.hostslug()}/"
1452
+ today = datetime.date.today().isoformat()
1453
+
1454
+ synced: list[str] = []
1455
+ seen: set[str] = set()
1456
+ # MEMORY.md is the harness's index/TOC, not content — skip it.
1457
+ for src in sorted(mem_dir.glob("*.md")):
1458
+ if src.name == "MEMORY.md" or not src.is_file():
1459
+ continue
1460
+ relpath = f"{prefix}{src.name}"
1461
+ seen.add(relpath)
1462
+ sfm, sbody = notes.parse(src.read_text())
1463
+ mtype = ((sfm.get("metadata") or {}) if isinstance(sfm.get("metadata"), dict)
1464
+ else {}).get("type")
1465
+ tags = list(dict.fromkeys(
1466
+ [*(sfm.get("tags") or []), "claude-memory", *( [mtype] if mtype else [])]))
1467
+ fm = {**sfm, "source": "claude_memory", "host": claudemem.hostslug(),
1468
+ "source_path": str(src), "memory_name": sfm.get("name"),
1469
+ "synced": today, "tags": tags}
1470
+ tgt = self.abspath(proj, relpath)
1471
+ prev_id = notes.load(tgt).id if tgt.exists() else None
1472
+ # Derived id (from the host-namespaced path) is stable across syncs,
1473
+ # so identity/history survive without a per-machine random ULID (§14).
1474
+ fm = {"id": prev_id or derived_ulid(relpath), **fm}
1475
+ note = Note(path=tgt, frontmatter=fm, body=sbody)
1476
+ note.path = tgt
1477
+ notes.save_atomic(note) # derived: bypass the version ring
1478
+ await self.index.index_file(proj, self.notes_dir(proj), relpath)
1479
+ synced.append(relpath)
1480
+
1481
+ removed = await self._reconcile_memory_dir(proj, prefix, seen)
1482
+ self.memory_bindings.upsert(src_root, proj)
1483
+ return {"project": proj, "source": str(mem_dir),
1484
+ "synced": len(synced), "removed": removed, "files": synced,
1485
+ "created": created}
1486
+
1487
+ async def _reconcile_memory_dir(self, proj: str, prefix: str,
1488
+ keep: set[str]) -> int:
1489
+ """Drop mirrored memory files gone upstream — scoped to THIS host's
1490
+ subdir, so a synced peer machine's memories are never reaped."""
1491
+ host_dir = self.notes_dir(proj) / prefix
1492
+ if not host_dir.is_dir():
1493
+ return 0
1494
+ removed = 0
1495
+ for f in sorted(host_dir.glob("*.md")):
1496
+ relpath = f"{prefix}{f.name}"
1497
+ if relpath not in keep:
1498
+ f.unlink()
1499
+ await self.index.index_file(proj, self.notes_dir(proj), relpath)
1500
+ removed += 1
1501
+ return removed
1502
+
1503
+ # --- versioning / git --------------------------------------------------
1504
+ def list_versions(self, relpath: str, project: str | None = None,
1505
+ cwd: Path | None = None) -> list[dict[str, Any]]:
1506
+ proj = self.resolve_project(project, cwd)
1507
+ return self.notestore.list_versions(proj, relpath)
1508
+
1509
+ async def restore(self, relpath: str, version: str, project: str | None = None,
1510
+ cwd: Path | None = None) -> dict[str, Any]:
1511
+ proj = self.resolve_project(project, cwd)
1512
+ content = self.notestore.version_content(proj, relpath, version)
1513
+ return await self.edit_note(relpath, content, project=proj)
1514
+
1515
+ def snapshot(self, message: str | None = None) -> str:
1516
+ return self.git.snapshot(message)
1517
+
1518
+ def history(self, relpath: str | None = None) -> list[str]:
1519
+ return self.git.history(relpath)
1520
+
1521
+ def projects(self) -> list[str]:
1522
+ pd = self.paths.projects_dir
1523
+ if not pd.is_dir():
1524
+ return []
1525
+ return sorted(p.name for p in pd.iterdir() if p.is_dir())
1526
+
1527
+ def use_project(self, project: str) -> dict[str, Any]:
1528
+ """Set the session's current project (mirrors the project_use MCP tool for
1529
+ the in-process CLI; session state is process-local here)."""
1530
+ from .session import session_state
1531
+ created = self.project_is_new(project)
1532
+ self.notes_dir(project) # eager mkdir — no phantom namespace
1533
+ session_state().current_project = project
1534
+ return {"current_project": project, "created": created}
1535
+
1536
+ def current_project(self, cwd: Path | None = None) -> dict[str, Any]:
1537
+ """Show the session's current project + how it resolved (mirrors the
1538
+ project_current MCP tool), seeding from cwd/.crib if unset."""
1539
+ from .session import resolve_session_project, session_state
1540
+ res = resolve_session_project(
1541
+ session_state(), None, cwd,
1542
+ lambda c: self.resolve_project(None, c),
1543
+ default=self.config.default_project)
1544
+ return {"current_project": res.project, "resolved_via": res.via,
1545
+ "projects": self.projects()}
1546
+
1547
+ def project_config(self, project: str) -> ProjectConfig:
1548
+ return ProjectConfig.load(
1549
+ self.paths.project_dir(project) / ".cribproject", project)
1550
+
1551
+
1552
+ def _build_store(paths: Paths, config: Config) -> tuple[Store, Callable[[], None] | None]:
1553
+ """Return (store, on_close). on_close releases the shared Chroma refcount."""
1554
+ from .store import JsonStore
1555
+
1556
+ mode = config.chroma.mode
1557
+ if mode == "shared":
1558
+ return _build_shared_chroma(paths, config)
1559
+ if mode == "embedded":
1560
+ try:
1561
+ from .store import ChromaStore
1562
+ return ChromaStore.embedded(str(paths.chroma_dir)), None
1563
+ except ImportError:
1564
+ pass # fall through to the dependency-free persistent store
1565
+ # mode == "json", or embedded requested but chromadb unavailable
1566
+ return JsonStore(paths.index_dir / "store.json"), None
1567
+
1568
+
1569
+ def _build_shared_chroma(paths: Paths, config: Config) -> tuple[Store, Callable[[], None]]:
1570
+ """Refcount a `chroma run` via sharedserver, wait for it, then connect.
1571
+
1572
+ `sharedserver use` is reuse-or-start by default: if `<server_name>` is already
1573
+ running it just attaches and increments the refcount; it only launches a new
1574
+ `chroma run` when none exists. crib releases its refcount on close."""
1575
+ from . import sharedserver
1576
+ from .store import ChromaStore
1577
+
1578
+ c = config.chroma
1579
+ paths.chroma_dir.mkdir(parents=True, exist_ok=True)
1580
+ command = ["chroma", "run", "--path", str(paths.chroma_dir),
1581
+ "--host", c.host, "--port", str(c.port)]
1582
+ sharedserver.use(c.server_name, command, c.grace_period)
1583
+
1584
+ def probe() -> None:
1585
+ import chromadb # lazy
1586
+ chromadb.HttpClient(host=c.host, port=c.port).heartbeat()
1587
+
1588
+ try:
1589
+ sharedserver.wait_ready(probe)
1590
+ store = ChromaStore.shared(c.host, c.port)
1591
+ except Exception:
1592
+ sharedserver.unuse(c.server_name) # don't leak the refcount on failure
1593
+ raise
1594
+ return store, (lambda: sharedserver.unuse(c.server_name))