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/__init__.py +20 -0
- crib/__main__.py +9 -0
- crib/app.py +1594 -0
- crib/chunk.py +218 -0
- crib/claudemem.py +100 -0
- crib/cli.py +1048 -0
- crib/client.py +105 -0
- crib/codeindex.py +1411 -0
- crib/codeindexer.py +335 -0
- crib/codequery.py +263 -0
- crib/codestore.py +270 -0
- crib/config.py +362 -0
- crib/describe_queue.py +107 -0
- crib/embed.py +188 -0
- crib/generate.py +133 -0
- crib/gitbacking.py +307 -0
- crib/indexer.py +158 -0
- crib/learnings.py +252 -0
- crib/memmirror.py +113 -0
- crib/merge.py +144 -0
- crib/notes.py +156 -0
- crib/notestore.py +156 -0
- crib/paths.py +65 -0
- crib/project_services.py +51 -0
- crib/refs.py +134 -0
- crib/retrieve.py +365 -0
- crib/section_index.py +157 -0
- crib/server.py +660 -0
- crib/session.py +95 -0
- crib/sharedserver.py +57 -0
- crib/sources.py +70 -0
- crib/store.py +256 -0
- crib/util.py +56 -0
- crib/versions.py +66 -0
- crib/watch.py +281 -0
- cribsheet-0.2.0.dist-info/METADATA +232 -0
- cribsheet-0.2.0.dist-info/RECORD +39 -0
- cribsheet-0.2.0.dist-info/WHEEL +4 -0
- cribsheet-0.2.0.dist-info/entry_points.txt +2 -0
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))
|