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 ADDED
@@ -0,0 +1,3 @@
1
+ """snowpack: local-first agent memory for Claude Code."""
2
+
3
+ __version__ = "0.1.0"
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()