foldcrumbs 0.3.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.
foldcrumbs/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """foldcrumbs — persistent cross-session memory for coding agents.
2
+
3
+ File-based memory store + lifecycle hooks for Claude Code. No external
4
+ service, no vector DB: retrieval is done by the agent's own grep over the
5
+ memory folder, and a local OpenAI-compatible LLM is used only for async
6
+ distillation. Pure stdlib, so hook scripts never fail on a missing import.
7
+ """
8
+
9
+ __version__ = "0.3.0"
foldcrumbs/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
foldcrumbs/audit.py ADDED
@@ -0,0 +1,117 @@
1
+ """Store integrity audit + pruning.
2
+
3
+ Link integrity: the index links to real files on disk (``store.rebuild_index``
4
+ is path-based), so a dead link — or an active memory the index doesn't link —
5
+ just means the index is stale; ``heal_index`` rebuilds it. Pollution: a memory
6
+ whose title/content is a structural tooling artifact (markdown table, code
7
+ fence, status glyphs, the local-command caveat — distill's strict detector) is
8
+ never durable knowledge and can be pruned. The strict detector deliberately
9
+ excludes prose that merely mentions MEMORY.md so legitimate foldcrumbs design notes
10
+ are never deleted. Superseded/deleted records keep their
11
+ files but drop out of the index/recall; ``prune`` clears those too.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ from . import config, store
19
+ from .distill import _is_hard_artifact
20
+
21
+ _LINK_RE = re.compile(r"\]\(([^)]+\.md)\)")
22
+ # compute_confidence below this is low-trust (stale/contradicted); prune only on
23
+ # explicit request, never automatically.
24
+ STALE_CONF = 0.3
25
+
26
+
27
+ def _name(m) -> str:
28
+ return m.source_path or m.filename()
29
+
30
+
31
+ def _index_links(cwd=None) -> set[str]:
32
+ p = config.index_path(cwd)
33
+ if not p.exists():
34
+ return set()
35
+ try:
36
+ return set(_LINK_RE.findall(p.read_text(encoding="utf-8")))
37
+ except OSError:
38
+ return set()
39
+
40
+
41
+ def audit(cwd=None) -> dict:
42
+ """Read-only report: dead index links, orphaned active memories (on disk but
43
+ unlinked), artifact pollution, and low-trust/stale memories."""
44
+ linked = _index_links(cwd)
45
+ mems = list(store.iter_memories(cwd))
46
+ active = [m for m in mems if m.status == "active"]
47
+ on_disk = {_name(m) for m in mems}
48
+ active_names = {_name(m) for m in active}
49
+ return {
50
+ "dead_links": sorted(t for t in linked if t not in on_disk),
51
+ "orphans": sorted(n for n in active_names if n not in linked),
52
+ "pollution": sorted(_name(m) for m in active
53
+ if _is_hard_artifact(m.title) or _is_hard_artifact(m.content)),
54
+ "stale": sorted(_name(m) for m in active
55
+ if m.compute_confidence() < STALE_CONF),
56
+ "active": len(active),
57
+ "total": len(mems),
58
+ }
59
+
60
+
61
+ def heal_index(cwd=None) -> bool:
62
+ """Rebuild the index if it is stale (dead links or unlinked active memories).
63
+
64
+ Cheap and idempotent; returns True iff it rebuilt. Callers that share a store
65
+ across machines should gate this on ``config.distill_enabled()`` so only a
66
+ writing machine repairs (avoids sync churn)."""
67
+ a = audit(cwd)
68
+ if a["dead_links"] or a["orphans"]:
69
+ store.rebuild_index(cwd)
70
+ return True
71
+ return False
72
+
73
+
74
+ def _delete(name: str, cwd=None) -> bool:
75
+ try:
76
+ (config.memory_dir(cwd) / name).unlink()
77
+ return True
78
+ except OSError:
79
+ return False
80
+
81
+
82
+ def prune_artifacts(cwd=None) -> list[str]:
83
+ """Delete active memories whose text is a clear tooling artifact, then rebuild
84
+ the index. Conservative — only unambiguous artifacts. Returns deleted names."""
85
+ removed = [
86
+ _name(m)
87
+ for m in list(store.iter_memories(cwd))
88
+ if m.status == "active" and (_is_hard_artifact(m.title) or _is_hard_artifact(m.content))
89
+ ]
90
+ removed = [n for n in removed if _delete(n, cwd)]
91
+ if removed:
92
+ store.rebuild_index(cwd)
93
+ return removed
94
+
95
+
96
+ def prune(cwd=None, apply: bool = False, include_stale: bool = False) -> dict:
97
+ """Find (and with ``apply``, delete) prune candidates.
98
+
99
+ Candidates: superseded/deleted records (files left behind), active artifact
100
+ pollution, and — only with ``include_stale`` — low-trust active memories.
101
+ Dry-run by default; rebuilds the index when it deletes anything."""
102
+ candidates: dict[str, str] = {}
103
+ for m in store.iter_memories(cwd):
104
+ name = _name(m)
105
+ if m.status in ("deleted", "superseded"):
106
+ candidates[name] = "superseded/deleted"
107
+ elif m.status == "active" and (_is_hard_artifact(m.title) or _is_hard_artifact(m.content)):
108
+ candidates[name] = "artifact"
109
+ elif (include_stale and m.status == "active"
110
+ and m.compute_confidence() < STALE_CONF):
111
+ candidates[name] = "stale"
112
+ removed: list[str] = []
113
+ if apply and candidates:
114
+ removed = [n for n in candidates if _delete(n, cwd)]
115
+ if removed:
116
+ store.rebuild_index(cwd)
117
+ return {"candidates": candidates, "removed": removed, "applied": apply}
foldcrumbs/cli.py ADDED
@@ -0,0 +1,358 @@
1
+ """foldcrumbs CLI (stdlib argparse).
2
+
3
+ Commands:
4
+ remember store a memory
5
+ recall search the store (substring + fuzzy) and render a context block
6
+ index rebuild MEMORY.md
7
+ distill distill a transcript/text file into memories (uses the LLM)
8
+ status show config + store stats
9
+ install merge hooks into Claude Code settings.json
10
+ migrate move a legacy engram install to foldcrumbs
11
+ uninstall remove foldcrumbs hooks
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from . import config, distill, install, llm, store
21
+ from .profile import format_context_block
22
+ from .schema import VALID_TYPES, MemoryRecord
23
+
24
+
25
+ def _cmd_remember(args: argparse.Namespace) -> int:
26
+ rec = MemoryRecord(
27
+ title=args.title or args.text[:80],
28
+ content=args.text,
29
+ type=args.type,
30
+ confidence=args.confidence,
31
+ provenance="explicit_statement",
32
+ source="cli",
33
+ tags=args.tag or [],
34
+ )
35
+ action, path = store.upsert(rec)
36
+ store.rebuild_index()
37
+ print(f"{action}: {path}")
38
+ return 0
39
+
40
+
41
+ def _cmd_recall(args: argparse.Namespace) -> int:
42
+ top = store.search(args.query, limit=args.limit)
43
+ block = format_context_block(top, heading=args.query)
44
+ print(block or "(no matching memories)")
45
+ return 0
46
+
47
+
48
+ def _cmd_answer(args: argparse.Namespace) -> int:
49
+ mems = store.search(args.question, limit=args.limit)
50
+ if not mems:
51
+ print("(no relevant memories found)")
52
+ return 0
53
+ context = "\n".join(f"- [{m.type}] {m.content}" for m in mems)
54
+ out = llm.chat(
55
+ messages=[
56
+ {"role": "system", "content": "Answer the question using ONLY the "
57
+ "provided project memories. If they don't cover it, say so."},
58
+ {"role": "user", "content": f"Memories:\n{context}\n\nQuestion: {args.question}"},
59
+ ],
60
+ temperature=0.1,
61
+ )
62
+ print(out or f"(LLM unavailable — relevant memories)\n{context}")
63
+ return 0
64
+
65
+
66
+ def _cmd_checkpoint(args: argparse.Namespace) -> int:
67
+ text = Path(args.file).read_text(encoding="utf-8") if args.file else sys.stdin.read()
68
+ handoff = distill.make_handoff(text)
69
+ if not handoff:
70
+ print("(nothing to checkpoint)")
71
+ return 0
72
+ path = store.write_handoff(handoff)
73
+ print(f"handoff written: {path}")
74
+ return 0
75
+
76
+
77
+ def _cmd_handoff(_: argparse.Namespace) -> int:
78
+ print(store.read_handoff() or "(no handoff yet)")
79
+ return 0
80
+
81
+
82
+ def _cmd_index(_: argparse.Namespace) -> int:
83
+ print(f"rebuilt: {store.rebuild_index()}")
84
+ return 0
85
+
86
+
87
+ def _cmd_distill(args: argparse.Namespace) -> int:
88
+ text = Path(args.file).read_text(encoding="utf-8") if args.file else sys.stdin.read()
89
+ if not llm.available():
90
+ print("warning: LLM endpoint unreachable — using heuristic fallback",
91
+ file=sys.stderr)
92
+ res = distill.distill_and_store(text, source="cli-distill")
93
+ print(f"distilled: {res}")
94
+ return 0
95
+
96
+
97
+ def _cmd_doctor(_: argparse.Namespace) -> int:
98
+ from foldcrumbs import audit
99
+ a = audit.audit()
100
+ print(f"memories : {a['active']} active / {a['total']} total")
101
+ print(f"dead links : {len(a['dead_links'])}" + (f" {a['dead_links']}" if a['dead_links'] else ""))
102
+ print(f"orphans : {len(a['orphans'])}" + (f" {a['orphans']}" if a['orphans'] else ""))
103
+ print(f"pollution : {len(a['pollution'])}" + (f" {a['pollution']}" if a['pollution'] else ""))
104
+ print(f"low-trust : {len(a['stale'])}" + (f" {a['stale']}" if a['stale'] else ""))
105
+ if a["dead_links"] or a["orphans"]:
106
+ print("hint: run `foldcrumbs index` to rebuild, or `foldcrumbs doctor` after a distill.")
107
+ if a["pollution"]:
108
+ print("hint: run `foldcrumbs prune` (dry-run) then `foldcrumbs prune --apply`.")
109
+ return 0
110
+
111
+
112
+ def _cmd_prune(args: argparse.Namespace) -> int:
113
+ from foldcrumbs import audit
114
+ res = audit.prune(apply=args.apply, include_stale=args.include_stale)
115
+ if not res["candidates"]:
116
+ print("nothing to prune.")
117
+ return 0
118
+ for name, reason in sorted(res["candidates"].items()):
119
+ mark = "removed" if name in res["removed"] else ("would remove" if not args.apply else "kept")
120
+ print(f" [{reason}] {name} — {mark}")
121
+ if not args.apply:
122
+ print(f"\n{len(res['candidates'])} candidate(s). Re-run with --apply to delete.")
123
+ else:
124
+ print(f"\nremoved {len(res['removed'])} file(s); index rebuilt.")
125
+ return 0
126
+
127
+
128
+ def _cmd_status(_: argparse.Namespace) -> int:
129
+ mems = store.load_all()
130
+ active = [m for m in mems if m.status == "active"]
131
+ print(f"memory dir : {config.memory_dir()}")
132
+ print(f"index : {config.index_path()}")
133
+ print(f"memories : {len(active)} active / {len(mems)} total")
134
+ backend = config.llm_backend()
135
+ if backend == "claude-cli":
136
+ print(f"LLM backend: claude-cli ({config.claude_bin()})")
137
+ elif backend == "codex":
138
+ print(f"LLM backend: codex ({config.codex_bin()})")
139
+ elif backend in config._NO_LLM_BACKENDS:
140
+ print("LLM backend: none — keyword heuristic only")
141
+ else:
142
+ print(f"LLM backend: openai — {config.LLM_ENDPOINT} (model {config.LLM_MODEL})")
143
+ print(f"LLM reachable: {llm.available()}")
144
+ print(f"distill here : {'on' if config.distill_enabled() else 'off (read-only consumer)'}")
145
+ print(f"context budget: {config.CONTEXT_BUDGET} @ {int(config.CONTEXT_PCT*100)}%")
146
+ return 0
147
+
148
+
149
+ def _cmd_migrate(args: argparse.Namespace) -> int:
150
+ """Migrate a legacy engram install to foldcrumbs (non-destructive).
151
+
152
+ 1. State dir: copy ~/.engram -> ~/.foldcrumbs (backend choice, CLI bins,
153
+ checkpoint flags) when the new dir doesn't exist yet. The source is never
154
+ deleted, so a machine can be rolled back.
155
+ 2. Memory (opt-in): with --from <old-project-dir>, copy that project's memory
156
+ store into the *current* project's memory dir (deterministic slug). Never
157
+ overwrites a non-empty target unless --force; never deletes the source.
158
+
159
+ Recall still needs no LLM; this only moves files. Back-compat in config.py
160
+ means foldcrumbs already reads a legacy ~/.engram, so this is about making the
161
+ move explicit, not about restoring function.
162
+ """
163
+ import shutil
164
+
165
+ old_state = Path.home() / ".engram"
166
+ new_state = Path.home() / ".foldcrumbs"
167
+ if new_state.exists():
168
+ print(f"state : {new_state} already exists — skipped")
169
+ elif old_state.exists():
170
+ shutil.copytree(old_state, new_state)
171
+ print(f"state : copied {old_state} -> {new_state}")
172
+ else:
173
+ print("state : no ~/.engram to migrate")
174
+
175
+ if args.from_dir:
176
+ src = config.memory_dir(args.from_dir)
177
+ dst = config.memory_dir()
178
+ if not src.exists():
179
+ print(f"memory: source {src} not found — nothing to copy")
180
+ elif src.resolve() == dst.resolve():
181
+ print(f"memory: source and target are the same ({dst}) — skipped")
182
+ elif dst.exists() and any(dst.iterdir()) and not args.force:
183
+ print(f"memory: target {dst} not empty — pass --force to merge")
184
+ return 1
185
+ else:
186
+ dst.mkdir(parents=True, exist_ok=True)
187
+ for item in src.iterdir():
188
+ target = dst / item.name
189
+ if item.is_dir():
190
+ shutil.copytree(item, target, dirs_exist_ok=True)
191
+ else:
192
+ shutil.copy2(item, target)
193
+ print(f"memory: copied {src} -> {dst}")
194
+ else:
195
+ print(f"memory: (skipped) pass --from <old-project-dir> to copy its store "
196
+ f"into {config.memory_dir()}")
197
+ print("done. reinstall hooks with `foldcrumbs install` if not already.")
198
+ return 0
199
+
200
+
201
+ def _cmd_install(args: argparse.Namespace) -> int:
202
+ agent = args.agent
203
+ if agent == "opencode":
204
+ paths = install.opencode_paths(global_scope=not args.local)
205
+ mcp = install.install_opencode_mcp(paths["config"])
206
+ plugin = install.write_opencode_plugin(paths["plugins"])
207
+ agents = install.append_agents_md(paths["agents"])
208
+ print(f"opencode.json mcp: {mcp or '(already present)'} ({paths['config']})")
209
+ print(f"plugin: {plugin}")
210
+ print(f"AGENTS.md: {agents or '(block already present)'}")
211
+ return 0
212
+
213
+ path = Path(args.settings) if args.settings else install.default_settings_path(
214
+ agent=agent, global_scope=not args.local
215
+ )
216
+ changes = install.install_hooks(path, agent=agent)
217
+ print(f"settings: {path}")
218
+ print("added:", changes or "(nothing — already installed)")
219
+ if agent == "codex":
220
+ print("codex MCP (config.toml):", install.install_codex_mcp_toml())
221
+ _configure_backend_at_install(args)
222
+ return 0
223
+
224
+
225
+ def _configure_backend_at_install(args: argparse.Namespace) -> None:
226
+ """Pick the LLM distillation backend during install.
227
+
228
+ Explicit ``--backend`` wins. Otherwise prompt interactively when on a TTY;
229
+ when non-interactive (piped/CI) leave the existing choice untouched and say
230
+ how to set it later.
231
+ """
232
+ if getattr(args, "no_backend_prompt", False):
233
+ return
234
+ choice = getattr(args, "backend", None)
235
+ if not choice:
236
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
237
+ print("LLM backend: left as-is "
238
+ f"({config.llm_backend()}); set later with `foldcrumbs backend <name>`")
239
+ return
240
+ choice = install.prompt_backend()
241
+ if not choice:
242
+ return
243
+ written = install.configure_backend(choice)
244
+ print(f"LLM backend: {choice} -> wrote {', '.join(written)} in {config.STATE_DIR}")
245
+
246
+
247
+ def _cmd_backend(args: argparse.Namespace) -> int:
248
+ """Set (or, with no argument, show) the machine-local LLM backend."""
249
+ if not args.choice:
250
+ backend = config.llm_backend()
251
+ print(f"LLM backend: {backend}")
252
+ print(f"reachable : {llm.available()}")
253
+ print("choices :", ", ".join(k for k, _ in install.BACKEND_CHOICES))
254
+ return 0
255
+ written = install.configure_backend(
256
+ args.choice, bin_path=args.bin, endpoint=args.endpoint, model=args.model)
257
+ print(f"LLM backend: {args.choice} -> wrote {', '.join(written)} in {config.STATE_DIR}")
258
+ print(f"reachable : {llm.available()}")
259
+ return 0
260
+
261
+
262
+ def _cmd_uninstall(args: argparse.Namespace) -> int:
263
+ path = Path(args.settings) if args.settings else install.default_settings_path(
264
+ agent=args.agent, global_scope=not args.local
265
+ )
266
+ removed = install.uninstall_hooks(path)
267
+ print(f"removed from {path}: {removed or '(nothing)'}")
268
+ return 0
269
+
270
+
271
+ def build_parser() -> argparse.ArgumentParser:
272
+ p = argparse.ArgumentParser(prog="foldcrumbs", description=__doc__)
273
+ sub = p.add_subparsers(dest="cmd", required=True)
274
+
275
+ r = sub.add_parser("remember", help="store a memory")
276
+ r.add_argument("text")
277
+ r.add_argument("--type", default="fact", choices=sorted(VALID_TYPES))
278
+ r.add_argument("--title", default="")
279
+ r.add_argument("--confidence", type=float, default=0.85)
280
+ r.add_argument("--tag", action="append")
281
+ r.set_defaults(func=_cmd_remember)
282
+
283
+ rc = sub.add_parser("recall", help="search the store")
284
+ rc.add_argument("query")
285
+ rc.add_argument("--limit", type=int, default=10)
286
+ rc.set_defaults(func=_cmd_recall)
287
+
288
+ an = sub.add_parser("answer", help="answer a question grounded in memory (LLM)")
289
+ an.add_argument("question")
290
+ an.add_argument("--limit", type=int, default=8)
291
+ an.set_defaults(func=_cmd_answer)
292
+
293
+ sub.add_parser("index", help="rebuild MEMORY.md").set_defaults(func=_cmd_index)
294
+
295
+ cp = sub.add_parser("checkpoint", help="write a working-state handoff (LLM)")
296
+ cp.add_argument("file", nargs="?", help="transcript/text file (default: stdin)")
297
+ cp.set_defaults(func=_cmd_checkpoint)
298
+
299
+ sub.add_parser("handoff", help="print the current handoff").set_defaults(
300
+ func=_cmd_handoff)
301
+
302
+ d = sub.add_parser("distill", help="distill a transcript/text into memories")
303
+ d.add_argument("file", nargs="?", help="path to text file (default: stdin)")
304
+ d.set_defaults(func=_cmd_distill)
305
+
306
+ sub.add_parser("status", help="show config + stats").set_defaults(func=_cmd_status)
307
+
308
+ mg = sub.add_parser("migrate", help="migrate a legacy engram install to foldcrumbs")
309
+ mg.add_argument("--from", dest="from_dir", metavar="OLD_PROJECT_DIR",
310
+ help="also copy this project's memory store into the current one")
311
+ mg.add_argument("--force", action="store_true",
312
+ help="merge into a non-empty target memory dir")
313
+ mg.set_defaults(func=_cmd_migrate)
314
+
315
+ sub.add_parser("doctor", help="audit store: dead links, orphans, pollution"
316
+ ).set_defaults(func=_cmd_doctor)
317
+
318
+ pr = sub.add_parser("prune", help="delete pollution / superseded memories (dry-run by default)")
319
+ pr.add_argument("--apply", action="store_true", help="actually delete (default: dry-run)")
320
+ pr.add_argument("--include-stale", action="store_true",
321
+ help="also prune low-trust memories")
322
+ pr.set_defaults(func=_cmd_prune)
323
+
324
+ ins = sub.add_parser("install", help="wire foldcrumbs into a coding agent")
325
+ ins.add_argument("--agent", choices=["claude", "codex", "opencode"], default="claude")
326
+ ins.add_argument("--local", action="store_true", help="project scope instead of global")
327
+ ins.add_argument("--settings", help="explicit settings.json path")
328
+ ins.add_argument("--backend", choices=list(config.BACKENDS),
329
+ help="LLM distill backend (skip the interactive prompt)")
330
+ ins.add_argument("--no-backend-prompt", action="store_true",
331
+ dest="no_backend_prompt",
332
+ help="don't ask about / change the LLM backend")
333
+ ins.set_defaults(func=_cmd_install)
334
+
335
+ bk = sub.add_parser("backend", help="show or set the LLM distill backend")
336
+ bk.add_argument("choice", nargs="?", choices=list(config.BACKENDS),
337
+ help="backend to select (omit to show current)")
338
+ bk.add_argument("--bin", help="explicit CLI path for claude-cli/codex")
339
+ bk.add_argument("--endpoint", help="HTTP endpoint for the openai backend")
340
+ bk.add_argument("--model", help="model id for the openai backend")
341
+ bk.set_defaults(func=_cmd_backend)
342
+
343
+ uns = sub.add_parser("uninstall", help="remove foldcrumbs hooks")
344
+ uns.add_argument("--agent", choices=["claude", "codex", "opencode"], default="claude")
345
+ uns.add_argument("--local", action="store_true")
346
+ uns.add_argument("--settings")
347
+ uns.set_defaults(func=_cmd_uninstall)
348
+
349
+ return p
350
+
351
+
352
+ def main(argv: list[str] | None = None) -> int:
353
+ args = build_parser().parse_args(argv)
354
+ return args.func(args)
355
+
356
+
357
+ if __name__ == "__main__":
358
+ sys.exit(main())