snowpack 0.1.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.
- snowpack/__init__.py +3 -0
- snowpack/chunker.py +95 -0
- snowpack/cli.py +510 -0
- snowpack/config.py +121 -0
- snowpack/db.py +64 -0
- snowpack/extraction.py +174 -0
- snowpack/ingest.py +210 -0
- snowpack/models.py +124 -0
- snowpack/pit.py +185 -0
- snowpack/pit_static/app.js +284 -0
- snowpack/pit_static/force-graph.min.js +9 -0
- snowpack/pit_static/index.html +62 -0
- snowpack/pit_static/style.css +95 -0
- snowpack/providers/__init__.py +53 -0
- snowpack/providers/base.py +37 -0
- snowpack/providers/ollama.py +63 -0
- snowpack/providers/openai_compat.py +77 -0
- snowpack/reconcile.py +133 -0
- snowpack/retrieval.py +159 -0
- snowpack/schema.sql +323 -0
- snowpack/sinter.py +134 -0
- snowpack/storage.py +1061 -0
- snowpack/transcripts.py +198 -0
- snowpack-0.1.0.dist-info/METADATA +240 -0
- snowpack-0.1.0.dist-info/RECORD +27 -0
- snowpack-0.1.0.dist-info/WHEEL +4 -0
- snowpack-0.1.0.dist-info/entry_points.txt +2 -0
snowpack/__init__.py
ADDED
snowpack/chunker.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Exchanges -> Chunks. Pure functions, no I/O.
|
|
2
|
+
|
|
3
|
+
Bump config.CHUNKER_VERSION when chunking logic changes meaningfully —
|
|
4
|
+
re-chunking existing episodes is an explicit `snowpack reindex --rechunk`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .models import Chunk, Exchange, estimate_tokens, normalize_timestamp
|
|
10
|
+
|
|
11
|
+
CHUNK_BUDGET_TOKENS = 1000
|
|
12
|
+
MERGE_BELOW_TOKENS = 150
|
|
13
|
+
CONT_HEADER_CHARS = 120
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def chunk_exchanges(exchanges: list[Exchange],
|
|
17
|
+
budget_tokens: int = CHUNK_BUDGET_TOKENS,
|
|
18
|
+
merge_below: int = MERGE_BELOW_TOKENS) -> list[Chunk]:
|
|
19
|
+
"""Default: one chunk per exchange. Oversized exchanges split at part
|
|
20
|
+
boundaries; consecutive tiny exchanges merge so 'ok'/'yes' turns don't
|
|
21
|
+
flood the store with micro-episodes.
|
|
22
|
+
"""
|
|
23
|
+
chunks: list[Chunk] = []
|
|
24
|
+
small: list[tuple[str, str]] = [] # (rendered, occurred_at)
|
|
25
|
+
|
|
26
|
+
def flush_small() -> None:
|
|
27
|
+
if not small:
|
|
28
|
+
return
|
|
29
|
+
text = "\n\n---\n\n".join(t for t, _ in small)
|
|
30
|
+
chunks.append(Chunk(text, small[0][1], estimate_tokens(text)))
|
|
31
|
+
small.clear()
|
|
32
|
+
|
|
33
|
+
for ex in exchanges:
|
|
34
|
+
text = ex.render()
|
|
35
|
+
tokens = estimate_tokens(text)
|
|
36
|
+
if tokens < merge_below:
|
|
37
|
+
small.append((text, ex.occurred_at))
|
|
38
|
+
if sum(estimate_tokens(t) for t, _ in small) >= budget_tokens // 2:
|
|
39
|
+
flush_small()
|
|
40
|
+
continue
|
|
41
|
+
flush_small()
|
|
42
|
+
if tokens <= budget_tokens:
|
|
43
|
+
chunks.append(Chunk(text, ex.occurred_at, tokens))
|
|
44
|
+
else:
|
|
45
|
+
chunks.extend(_split_exchange(ex, budget_tokens))
|
|
46
|
+
|
|
47
|
+
flush_small()
|
|
48
|
+
return chunks
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _split_exchange(ex: Exchange, budget_tokens: int) -> list[Chunk]:
|
|
52
|
+
"""Split an oversized exchange at part boundaries into <= budget chunks.
|
|
53
|
+
|
|
54
|
+
Continuation chunks get a one-line header so each embeds with context.
|
|
55
|
+
"""
|
|
56
|
+
user_first_line = " ".join(ex.user_text.split())
|
|
57
|
+
cont_header = f"(cont.) user asked: {user_first_line[:CONT_HEADER_CHARS]}"
|
|
58
|
+
head = f"## User ({normalize_timestamp(ex.occurred_at)})\n{ex.user_text.strip()}"
|
|
59
|
+
|
|
60
|
+
segments = [head]
|
|
61
|
+
if ex.parts:
|
|
62
|
+
segments.append("## Assistant")
|
|
63
|
+
segments.extend(p.strip() for p in ex.parts if p.strip())
|
|
64
|
+
|
|
65
|
+
# Hard-split any single segment that alone exceeds the budget.
|
|
66
|
+
max_chars = budget_tokens * 4
|
|
67
|
+
flat: list[str] = []
|
|
68
|
+
for seg in segments:
|
|
69
|
+
while estimate_tokens(seg) > budget_tokens:
|
|
70
|
+
flat.append(seg[:max_chars])
|
|
71
|
+
seg = seg[max_chars:]
|
|
72
|
+
if seg:
|
|
73
|
+
flat.append(seg)
|
|
74
|
+
|
|
75
|
+
chunks: list[Chunk] = []
|
|
76
|
+
current: list[str] = []
|
|
77
|
+
current_tokens = 0
|
|
78
|
+
for seg in flat:
|
|
79
|
+
seg_tokens = estimate_tokens(seg)
|
|
80
|
+
header_cost = 0 if (current or not chunks) else estimate_tokens(cont_header)
|
|
81
|
+
if current and current_tokens + seg_tokens > budget_tokens:
|
|
82
|
+
text = "\n".join(current)
|
|
83
|
+
chunks.append(Chunk(text, ex.occurred_at, estimate_tokens(text)))
|
|
84
|
+
current = []
|
|
85
|
+
current_tokens = 0
|
|
86
|
+
header_cost = estimate_tokens(cont_header)
|
|
87
|
+
if not current and chunks:
|
|
88
|
+
current.append(cont_header)
|
|
89
|
+
current_tokens = header_cost
|
|
90
|
+
current.append(seg)
|
|
91
|
+
current_tokens += seg_tokens
|
|
92
|
+
if current:
|
|
93
|
+
text = "\n".join(current)
|
|
94
|
+
chunks.append(Chunk(text, ex.occurred_at, estimate_tokens(text)))
|
|
95
|
+
return chunks
|
snowpack/cli.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""snowpack CLI: the agent-facing contract. Thin wiring only — logic lives
|
|
2
|
+
in ingest/retrieval/storage.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from . import config
|
|
14
|
+
from . import db as snowdb
|
|
15
|
+
from .ingest import SETTLE_SECONDS, ingest_all
|
|
16
|
+
from .providers import ProviderUnavailable, get_embedder
|
|
17
|
+
from .retrieval import probe as run_probe
|
|
18
|
+
from .storage import Storage
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False,
|
|
21
|
+
help="Local-first agent memory for Claude Code.")
|
|
22
|
+
obs_app = typer.Typer(no_args_is_help=True, help="Episode ingest and listing.")
|
|
23
|
+
app.add_typer(obs_app, name="obs")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _open() -> Storage:
|
|
27
|
+
try:
|
|
28
|
+
conn = snowdb.connect(config.db_path())
|
|
29
|
+
except snowdb.SchemaError as e:
|
|
30
|
+
typer.echo(f"error: {e}", err=True)
|
|
31
|
+
raise typer.Exit(2) from e
|
|
32
|
+
return Storage(conn)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _embedder(storage: Storage):
|
|
36
|
+
return get_embedder(storage.meta())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_project(storage: Storage, project: str | None,
|
|
40
|
+
all_projects: bool) -> int | None:
|
|
41
|
+
"""slug flag > cwd auto-detect > warn and widen to all projects."""
|
|
42
|
+
if all_projects:
|
|
43
|
+
return None
|
|
44
|
+
if project:
|
|
45
|
+
p = storage.get_project_by_slug(project)
|
|
46
|
+
if p is None:
|
|
47
|
+
known = ", ".join(pr.slug for pr in storage.list_projects()) or "(none)"
|
|
48
|
+
typer.echo(f"error: unknown project {project!r}; known: {known}", err=True)
|
|
49
|
+
raise typer.Exit(2)
|
|
50
|
+
return p.id
|
|
51
|
+
found = storage.find_project_for_cwd(os.getcwd())
|
|
52
|
+
if found is None:
|
|
53
|
+
typer.echo("note: cwd not in a known project; searching all projects",
|
|
54
|
+
err=True)
|
|
55
|
+
return None
|
|
56
|
+
return found.id
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command()
|
|
60
|
+
def init(
|
|
61
|
+
model: str = typer.Option(config.DEFAULT_EMBEDDING_MODEL,
|
|
62
|
+
help="Embedding model (must be pulled in Ollama)."),
|
|
63
|
+
dim: int = typer.Option(None,
|
|
64
|
+
help="Embedding dimension. Usually omit: detected "
|
|
65
|
+
"from the running provider, or looked up for "
|
|
66
|
+
"known models."),
|
|
67
|
+
provider: str = typer.Option(config.DEFAULT_EMBEDDING_PROVIDER,
|
|
68
|
+
help="Embedding provider: ollama."),
|
|
69
|
+
):
|
|
70
|
+
"""Create and configure ~/.snowpack/snowpack.db.
|
|
71
|
+
|
|
72
|
+
The embedding dimension is baked into the database at init (vec0 columns
|
|
73
|
+
are fixed-width), so init verifies it against the live model when it can.
|
|
74
|
+
"""
|
|
75
|
+
from .providers import detect_embedding_dim
|
|
76
|
+
|
|
77
|
+
detected = detect_embedding_dim(provider, model)
|
|
78
|
+
known = config.KNOWN_MODEL_DIMS.get(model)
|
|
79
|
+
if dim is None:
|
|
80
|
+
dim = detected or known
|
|
81
|
+
if dim is None:
|
|
82
|
+
typer.echo(
|
|
83
|
+
f"error: can't determine the embedding dimension for "
|
|
84
|
+
f"{model!r}: the provider isn't reachable and the model "
|
|
85
|
+
f"isn't in the known list. Start it (e.g. `ollama serve` + "
|
|
86
|
+
f"`ollama pull {model}`) or pass --dim explicitly.",
|
|
87
|
+
err=True)
|
|
88
|
+
raise typer.Exit(2)
|
|
89
|
+
elif detected is not None and detected != dim:
|
|
90
|
+
typer.echo(
|
|
91
|
+
f"error: model {model!r} produces {detected}-d embeddings, "
|
|
92
|
+
f"but --dim {dim} was given. Re-run without --dim, or with "
|
|
93
|
+
f"--dim {detected}.",
|
|
94
|
+
err=True)
|
|
95
|
+
raise typer.Exit(2)
|
|
96
|
+
|
|
97
|
+
path = config.db_path()
|
|
98
|
+
conn = snowdb.connect(path, create=True)
|
|
99
|
+
cfg = config.EmbeddingConfig.default(model=model, dim=dim, provider=provider)
|
|
100
|
+
try:
|
|
101
|
+
snowdb.init_schema(conn, dim, cfg.init_meta())
|
|
102
|
+
except snowdb.SchemaError as e:
|
|
103
|
+
typer.echo(f"error: {e}", err=True)
|
|
104
|
+
raise typer.Exit(2) from e
|
|
105
|
+
typer.echo(f"initialized {path} (model={model}, dim={dim})")
|
|
106
|
+
if detected is None:
|
|
107
|
+
typer.echo(
|
|
108
|
+
f"note: embedding provider not reachable — dimension {dim} taken "
|
|
109
|
+
f"from {'--dim' if known is None else 'the known-models table'} "
|
|
110
|
+
f"and not verified. Episodes will ingest without vectors until "
|
|
111
|
+
f"Ollama is up (`ollama serve`, `ollama pull {model}`); the next "
|
|
112
|
+
f"`snowpack obs ingest` backfills them.",
|
|
113
|
+
err=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@obs_app.command("ingest")
|
|
117
|
+
def obs_ingest(
|
|
118
|
+
settle: int = typer.Option(SETTLE_SECONDS,
|
|
119
|
+
help="Seconds a file must be idle before its "
|
|
120
|
+
"trailing exchange is ingested."),
|
|
121
|
+
quiet: bool = typer.Option(False, "--quiet", "-q"),
|
|
122
|
+
):
|
|
123
|
+
"""Ingest new Claude Code transcript exchanges (incremental, idempotent)."""
|
|
124
|
+
storage = _open()
|
|
125
|
+
report = ingest_all(storage, _embedder(storage),
|
|
126
|
+
config.claude_projects_dir(), settle_seconds=settle)
|
|
127
|
+
if report.embedder_down:
|
|
128
|
+
typer.echo("warning: embedding provider unreachable; episodes stored "
|
|
129
|
+
"without vectors (re-run ingest to backfill)", err=True)
|
|
130
|
+
for err in report.errors:
|
|
131
|
+
typer.echo(f"warning: {err}", err=True)
|
|
132
|
+
if not quiet:
|
|
133
|
+
typer.echo(
|
|
134
|
+
f"files: {report.files_seen} seen, {report.files_processed} with new content; "
|
|
135
|
+
f"episodes: +{report.episodes_added} ({report.episodes_deduped} deduped); "
|
|
136
|
+
f"embedded: {report.embedded} episodes + {report.facts_embedded} "
|
|
137
|
+
f"facts ({report.embedding_pending} pending)"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@obs_app.command("extract")
|
|
142
|
+
def obs_extract(
|
|
143
|
+
limit: int = typer.Option(50, "--limit", "-n",
|
|
144
|
+
help="Max episodes to process this run."),
|
|
145
|
+
retry_failed: bool = typer.Option(False, "--retry-failed"),
|
|
146
|
+
):
|
|
147
|
+
"""Extract durable facts from un-extracted episodes (uses the API)."""
|
|
148
|
+
from .extraction import run_extraction
|
|
149
|
+
from .providers import get_extractor
|
|
150
|
+
|
|
151
|
+
storage = _open()
|
|
152
|
+
try:
|
|
153
|
+
extractor = get_extractor(storage.meta())
|
|
154
|
+
except ProviderUnavailable as e:
|
|
155
|
+
typer.echo(f"error: {e}", err=True)
|
|
156
|
+
raise typer.Exit(3) from e
|
|
157
|
+
report = run_extraction(storage, extractor, _embedder(storage),
|
|
158
|
+
limit=limit, retry_failed=retry_failed)
|
|
159
|
+
for err in report.errors:
|
|
160
|
+
typer.echo(f"warning: {err}", err=True)
|
|
161
|
+
f = report.facts
|
|
162
|
+
typer.echo(
|
|
163
|
+
f"episodes: {report.episodes_processed} processed, "
|
|
164
|
+
f"{report.episodes_failed} failed; "
|
|
165
|
+
f"facts: +{f.added} added, {f.superseded} superseded, "
|
|
166
|
+
f"{f.deduped} deduped, {f.dropped} dropped"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@obs_app.command("list")
|
|
171
|
+
def obs_list(
|
|
172
|
+
project: str = typer.Option(None, "--project", "-p"),
|
|
173
|
+
all_projects: bool = typer.Option(False, "--all-projects"),
|
|
174
|
+
limit: int = typer.Option(20, "--limit", "-n"),
|
|
175
|
+
):
|
|
176
|
+
"""List recent episodes."""
|
|
177
|
+
storage = _open()
|
|
178
|
+
pid = _resolve_project(storage, project, all_projects)
|
|
179
|
+
for row in storage.list_episodes(pid, limit):
|
|
180
|
+
snippet = " ".join(row.content.split())[:100]
|
|
181
|
+
typer.echo(f"[e{row.id}] {row.occurred_at} {row.project_slug:<16} {snippet}")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.command()
|
|
185
|
+
def probe(
|
|
186
|
+
query: str = typer.Argument(..., help="What to recall."),
|
|
187
|
+
project: str = typer.Option(None, "--project", "-p"),
|
|
188
|
+
all_projects: bool = typer.Option(False, "--all-projects"),
|
|
189
|
+
k: int = typer.Option(8, "-k"),
|
|
190
|
+
kind: str = typer.Option("hybrid", "--kind",
|
|
191
|
+
help="hybrid | episodes | facts"),
|
|
192
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
193
|
+
full: bool = typer.Option(False, "--full", help="Print full episode text."),
|
|
194
|
+
no_log: bool = typer.Option(False, "--no-log", help="Skip telemetry logging."),
|
|
195
|
+
):
|
|
196
|
+
"""Hybrid retrieval (vector + keyword + graph + recency) with telemetry."""
|
|
197
|
+
if kind not in ("hybrid", "episodes", "facts"):
|
|
198
|
+
typer.echo(f"error: bad --kind {kind!r}", err=True)
|
|
199
|
+
raise typer.Exit(2)
|
|
200
|
+
storage = _open()
|
|
201
|
+
pid = _resolve_project(storage, project, all_projects)
|
|
202
|
+
embedder = _embedder(storage)
|
|
203
|
+
res = run_probe(storage, embedder, query, project_id=pid, k=k, kind=kind,
|
|
204
|
+
session_id=os.environ.get("CLAUDE_SESSION_ID"),
|
|
205
|
+
log=not no_log)
|
|
206
|
+
if not res.vector_used:
|
|
207
|
+
typer.echo("note: embeddings unavailable; keyword+recency only", err=True)
|
|
208
|
+
|
|
209
|
+
def describe(h):
|
|
210
|
+
"""(date, project, text) for either memory kind."""
|
|
211
|
+
if h.kind == "episode":
|
|
212
|
+
ep = res.episodes.get(h.id)
|
|
213
|
+
if ep is None:
|
|
214
|
+
return None
|
|
215
|
+
return ep.occurred_at, ep.project_slug, ep.content
|
|
216
|
+
fact = res.facts.get(h.id)
|
|
217
|
+
if fact is None:
|
|
218
|
+
return None
|
|
219
|
+
return (fact["valid_from"], fact["project_slug"] or "global",
|
|
220
|
+
fact["statement"])
|
|
221
|
+
|
|
222
|
+
if json_out:
|
|
223
|
+
results = []
|
|
224
|
+
for h in res.hits:
|
|
225
|
+
d = describe(h)
|
|
226
|
+
if d is None:
|
|
227
|
+
continue
|
|
228
|
+
occurred_at, project_slug, content = d
|
|
229
|
+
results.append({
|
|
230
|
+
"id": f"{h.kind[0]}{h.id}",
|
|
231
|
+
"kind": h.kind,
|
|
232
|
+
"score": round(h.score, 4),
|
|
233
|
+
"channel": h.channel,
|
|
234
|
+
"channels": list(h.channels),
|
|
235
|
+
"occurred_at": occurred_at,
|
|
236
|
+
"project": project_slug,
|
|
237
|
+
"content": content,
|
|
238
|
+
})
|
|
239
|
+
typer.echo(json.dumps({
|
|
240
|
+
"retrieval_id": res.retrieval_id,
|
|
241
|
+
"latency_ms": res.latency_ms,
|
|
242
|
+
"results": results,
|
|
243
|
+
}, indent=2))
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
if not res.hits:
|
|
247
|
+
typer.echo("no memories found")
|
|
248
|
+
return
|
|
249
|
+
for h in res.hits:
|
|
250
|
+
d = describe(h)
|
|
251
|
+
if d is None:
|
|
252
|
+
continue
|
|
253
|
+
occurred_at, project_slug, content = d
|
|
254
|
+
date = occurred_at[:10]
|
|
255
|
+
if full:
|
|
256
|
+
typer.echo(f"[{h.kind[0]}{h.id}] {h.score:.3f} {date} {project_slug}")
|
|
257
|
+
typer.echo(content)
|
|
258
|
+
typer.echo("---")
|
|
259
|
+
else:
|
|
260
|
+
snippet = " ".join(content.split())[:160]
|
|
261
|
+
typer.echo(f"[{h.kind[0]}{h.id}] {h.score:.3f} {date} "
|
|
262
|
+
f"{project_slug:<16} {snippet}")
|
|
263
|
+
if res.retrieval_id is not None:
|
|
264
|
+
top = res.hits[0]
|
|
265
|
+
typer.echo(f"retrieval:{res.retrieval_id} mark useful: "
|
|
266
|
+
f"snowpack feedback {res.retrieval_id} --used {top.kind[0]}{top.id}")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _parse_memory_ids(raw: str) -> list[tuple[str, int]]:
|
|
270
|
+
kinds = {"e": "episode", "f": "fact"}
|
|
271
|
+
out = []
|
|
272
|
+
for piece in raw.split(","):
|
|
273
|
+
piece = piece.strip()
|
|
274
|
+
if not piece:
|
|
275
|
+
continue
|
|
276
|
+
if piece[0].lower() not in kinds or not piece[1:].isdigit():
|
|
277
|
+
typer.echo(f"error: bad memory id {piece!r} (expected e123 or f45)",
|
|
278
|
+
err=True)
|
|
279
|
+
raise typer.Exit(2)
|
|
280
|
+
out.append((kinds[piece[0].lower()], int(piece[1:])))
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@app.command()
|
|
285
|
+
def feedback(
|
|
286
|
+
retrieval_id: int = typer.Argument(...),
|
|
287
|
+
used: str = typer.Option("", "--used", help="Comma-separated ids, e.g. e123,f45."),
|
|
288
|
+
unused: str = typer.Option("", "--unused"),
|
|
289
|
+
):
|
|
290
|
+
"""Mark probe results as used/unused — the gold telemetry signal."""
|
|
291
|
+
storage = _open()
|
|
292
|
+
n = storage.set_feedback(retrieval_id, _parse_memory_ids(used),
|
|
293
|
+
_parse_memory_ids(unused))
|
|
294
|
+
storage.commit()
|
|
295
|
+
if n == 0:
|
|
296
|
+
typer.echo("warning: no matching retrieval results", err=True)
|
|
297
|
+
raise typer.Exit(1)
|
|
298
|
+
typer.echo(f"updated {n} result(s)")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@app.command()
|
|
302
|
+
def stash(
|
|
303
|
+
text: str = typer.Argument(None, help="Scratchpad markdown; '-' for stdin."),
|
|
304
|
+
project: str = typer.Option(None, "--project", "-p"),
|
|
305
|
+
show: bool = typer.Option(False, "--show", help="Print the current stash."),
|
|
306
|
+
archive: bool = typer.Option(False, "--archive",
|
|
307
|
+
help="Convert the stash into an episode and clear it."),
|
|
308
|
+
):
|
|
309
|
+
"""Working-memory checkpoint: one scratchpad per project.
|
|
310
|
+
|
|
311
|
+
Survives compaction and session restarts; archive it when a task completes.
|
|
312
|
+
"""
|
|
313
|
+
storage = _open()
|
|
314
|
+
pid = _resolve_project(storage, project, all_projects=False)
|
|
315
|
+
if pid is None:
|
|
316
|
+
typer.echo("error: no project for cwd; pass --project", err=True)
|
|
317
|
+
raise typer.Exit(2)
|
|
318
|
+
|
|
319
|
+
if show:
|
|
320
|
+
row = storage.get_session_state(pid)
|
|
321
|
+
if row is None:
|
|
322
|
+
typer.echo("no stash for this project")
|
|
323
|
+
else:
|
|
324
|
+
typer.echo(f"# stash (updated {row['updated_at']})")
|
|
325
|
+
typer.echo(row["state"])
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
if archive:
|
|
329
|
+
row = storage.get_session_state(pid)
|
|
330
|
+
if row is None:
|
|
331
|
+
typer.echo("error: nothing stashed to archive", err=True)
|
|
332
|
+
raise typer.Exit(1)
|
|
333
|
+
from .models import content_sha256, estimate_tokens
|
|
334
|
+
content = f"## Working-memory checkpoint (archived)\n{row['state']}"
|
|
335
|
+
session_id = row["session_id"] or "stash"
|
|
336
|
+
episode_id = storage.insert_episode(
|
|
337
|
+
project_id=pid, session_id=session_id, source_path=None,
|
|
338
|
+
content=content, content_hash=content_sha256(content),
|
|
339
|
+
token_count=estimate_tokens(content),
|
|
340
|
+
occurred_at=row["updated_at"],
|
|
341
|
+
)
|
|
342
|
+
storage.clear_session_state(pid)
|
|
343
|
+
storage.commit()
|
|
344
|
+
typer.echo(f"archived stash as episode e{episode_id}"
|
|
345
|
+
if episode_id else "archived (duplicate of an existing episode)")
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
if text is None:
|
|
349
|
+
typer.echo("error: provide TEXT, '-' for stdin, --show, or --archive",
|
|
350
|
+
err=True)
|
|
351
|
+
raise typer.Exit(2)
|
|
352
|
+
if text == "-":
|
|
353
|
+
text = sys.stdin.read()
|
|
354
|
+
if not text.strip():
|
|
355
|
+
typer.echo("error: empty stash", err=True)
|
|
356
|
+
raise typer.Exit(2)
|
|
357
|
+
storage.upsert_session_state(pid, os.environ.get("CLAUDE_SESSION_ID"), text)
|
|
358
|
+
storage.commit()
|
|
359
|
+
typer.echo("stashed")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@app.command()
|
|
363
|
+
def stats(
|
|
364
|
+
refresh: bool = typer.Option(False, "--refresh",
|
|
365
|
+
help="Recompute usefulness from telemetry first."),
|
|
366
|
+
):
|
|
367
|
+
"""Memory and telemetry overview (snotel)."""
|
|
368
|
+
storage = _open()
|
|
369
|
+
if refresh:
|
|
370
|
+
n = storage.refresh_memory_stats()
|
|
371
|
+
storage.commit()
|
|
372
|
+
typer.echo(f"refreshed usefulness for {n} memories")
|
|
373
|
+
s = storage.stats_summary()
|
|
374
|
+
typer.echo(
|
|
375
|
+
f"projects: {s['projects']} episodes: {s['episodes']} "
|
|
376
|
+
f"facts: {s['facts_current']} current / {s['facts_superseded']} superseded "
|
|
377
|
+
f"entities: {s['entities']}"
|
|
378
|
+
)
|
|
379
|
+
typer.echo(
|
|
380
|
+
f"retrievals: {s['retrievals']} zero-result gaps: {s['gaps']} "
|
|
381
|
+
f"pending embeddings: {s['pending_embeddings']}"
|
|
382
|
+
)
|
|
383
|
+
if s["channel_winrate"]:
|
|
384
|
+
typer.echo("channel win-rate (returned / used / rate):")
|
|
385
|
+
for row in s["channel_winrate"]:
|
|
386
|
+
typer.echo(f" {row['channel']:<8} {row['results_returned']:>6} "
|
|
387
|
+
f"{row['results_used']:>6} {row['use_rate']}")
|
|
388
|
+
if s["weak_layers"]:
|
|
389
|
+
typer.echo("persistent weak layers (retrieved, never used):")
|
|
390
|
+
for row in s["weak_layers"]:
|
|
391
|
+
stmt = " ".join(row["statement"].split())[:80]
|
|
392
|
+
typer.echo(f" [f{row['id']}] x{row['retrieval_count']} {stmt}")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
sinter_app = typer.Typer(no_args_is_help=False,
|
|
396
|
+
help="Procedural distillation: mine corrections "
|
|
397
|
+
"into CLAUDE.md candidates.")
|
|
398
|
+
app.add_typer(sinter_app, name="sinter")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@sinter_app.callback(invoke_without_command=True)
|
|
402
|
+
def sinter_run(ctx: typer.Context):
|
|
403
|
+
"""Mine episodes for repeated corrections and propose candidates."""
|
|
404
|
+
if ctx.invoked_subcommand is not None:
|
|
405
|
+
return
|
|
406
|
+
from .sinter import run_sinter
|
|
407
|
+
storage = _open()
|
|
408
|
+
report = run_sinter(storage)
|
|
409
|
+
typer.echo(f"mined {report.episodes_mined} correction episodes; "
|
|
410
|
+
f"{report.clusters_found} qualifying clusters; "
|
|
411
|
+
f"{report.candidates_created} new candidates")
|
|
412
|
+
if report.candidate_ids:
|
|
413
|
+
typer.echo("review with: snowpack sinter review")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@sinter_app.command("review")
|
|
417
|
+
def sinter_review():
|
|
418
|
+
"""List proposed candidates."""
|
|
419
|
+
storage = _open()
|
|
420
|
+
rows = storage.list_procedure_candidates("proposed")
|
|
421
|
+
if not rows:
|
|
422
|
+
typer.echo("no proposed candidates")
|
|
423
|
+
return
|
|
424
|
+
for row in rows:
|
|
425
|
+
typer.echo(f"--- candidate {row['id']} "
|
|
426
|
+
f"(evidence: {row['evidence_count']} episodes) ---")
|
|
427
|
+
typer.echo(row["rationale"] or "")
|
|
428
|
+
typer.echo(row["pattern"])
|
|
429
|
+
typer.echo("accept/reject with: snowpack sinter accept|reject <id>")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _decide(candidate_id: int, status: str) -> None:
|
|
433
|
+
storage = _open()
|
|
434
|
+
if not storage.decide_procedure_candidate(candidate_id, status):
|
|
435
|
+
typer.echo(f"error: no proposed candidate {candidate_id}", err=True)
|
|
436
|
+
raise typer.Exit(2)
|
|
437
|
+
storage.commit()
|
|
438
|
+
if status == "accepted":
|
|
439
|
+
typer.echo("accepted — paste the pattern into CLAUDE.md or a skill")
|
|
440
|
+
else:
|
|
441
|
+
typer.echo("rejected")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@sinter_app.command("accept")
|
|
445
|
+
def sinter_accept(candidate_id: int = typer.Argument(...)):
|
|
446
|
+
"""Accept a candidate (then paste its pattern into CLAUDE.md)."""
|
|
447
|
+
_decide(candidate_id, "accepted")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@sinter_app.command("reject")
|
|
451
|
+
def sinter_reject(candidate_id: int = typer.Argument(...)):
|
|
452
|
+
"""Reject a candidate; its episodes won't be re-proposed."""
|
|
453
|
+
_decide(candidate_id, "rejected")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@app.command()
|
|
457
|
+
def pit(
|
|
458
|
+
port: int = typer.Option(8617, "--port"),
|
|
459
|
+
no_browser: bool = typer.Option(False, "--no-browser"),
|
|
460
|
+
):
|
|
461
|
+
"""Open the pit: read-only graph + stats UI. Binds 127.0.0.1 only."""
|
|
462
|
+
storage = _open() # validates the DB exists before binding a port
|
|
463
|
+
storage.refresh_memory_stats()
|
|
464
|
+
storage.commit()
|
|
465
|
+
storage.conn.close()
|
|
466
|
+
from .pit import serve
|
|
467
|
+
try:
|
|
468
|
+
serve(config.db_path(), port=port, open_browser=not no_browser)
|
|
469
|
+
except OSError as e:
|
|
470
|
+
typer.echo(f"error: can't bind port {port} ({e.strerror}); "
|
|
471
|
+
f"try --port", err=True)
|
|
472
|
+
raise typer.Exit(2) from e
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
entity_app = typer.Typer(no_args_is_help=True, help="Entity maintenance.")
|
|
476
|
+
app.add_typer(entity_app, name="entity")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@entity_app.command("merge")
|
|
480
|
+
def entity_merge(
|
|
481
|
+
alias: str = typer.Argument(..., help="Duplicate entity name."),
|
|
482
|
+
canonical: str = typer.Argument(..., help="Entity it should point to."),
|
|
483
|
+
):
|
|
484
|
+
"""Mark one entity as an alias of another and repoint its facts."""
|
|
485
|
+
storage = _open()
|
|
486
|
+
a = storage.get_entity_by_name(alias)
|
|
487
|
+
c = storage.get_entity_by_name(canonical)
|
|
488
|
+
if a is None or c is None:
|
|
489
|
+
missing = alias if a is None else canonical
|
|
490
|
+
typer.echo(f"error: no entity named {missing!r}", err=True)
|
|
491
|
+
raise typer.Exit(2)
|
|
492
|
+
canonical_id = c["canonical_id"] or c["id"]
|
|
493
|
+
if a["id"] == canonical_id:
|
|
494
|
+
typer.echo("error: those are already the same entity", err=True)
|
|
495
|
+
raise typer.Exit(2)
|
|
496
|
+
storage.merge_entities(a["id"], canonical_id)
|
|
497
|
+
storage.commit()
|
|
498
|
+
typer.echo(f"merged {a['name']!r} -> {c['name']!r}")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def main() -> None: # pragma: no cover
|
|
502
|
+
try:
|
|
503
|
+
app()
|
|
504
|
+
except ProviderUnavailable as e:
|
|
505
|
+
typer.echo(f"error: {e}", err=True)
|
|
506
|
+
sys.exit(3)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
if __name__ == "__main__": # pragma: no cover
|
|
510
|
+
main()
|