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 +9 -0
- foldcrumbs/__main__.py +6 -0
- foldcrumbs/audit.py +117 -0
- foldcrumbs/cli.py +358 -0
- foldcrumbs/config.py +226 -0
- foldcrumbs/distill.py +376 -0
- foldcrumbs/hooks/__init__.py +0 -0
- foldcrumbs/hooks/_common.py +185 -0
- foldcrumbs/hooks/_state.py +57 -0
- foldcrumbs/hooks/_worker.py +57 -0
- foldcrumbs/hooks/context_monitor.py +74 -0
- foldcrumbs/hooks/post_compact.py +62 -0
- foldcrumbs/hooks/session_end.py +43 -0
- foldcrumbs/hooks/session_start.py +82 -0
- foldcrumbs/install.py +361 -0
- foldcrumbs/llm.py +200 -0
- foldcrumbs/mcp_server.py +222 -0
- foldcrumbs/profile.py +75 -0
- foldcrumbs/redact.py +44 -0
- foldcrumbs/schema.py +254 -0
- foldcrumbs/store.py +265 -0
- foldcrumbs-0.3.0.dist-info/METADATA +211 -0
- foldcrumbs-0.3.0.dist-info/RECORD +27 -0
- foldcrumbs-0.3.0.dist-info/WHEEL +5 -0
- foldcrumbs-0.3.0.dist-info/entry_points.txt +3 -0
- foldcrumbs-0.3.0.dist-info/licenses/LICENSE +26 -0
- foldcrumbs-0.3.0.dist-info/top_level.txt +1 -0
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
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())
|