Coverage for src \ truenex_memory \ cli \ main.py: 88%
525 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-19 10:21 +0200
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-19 10:21 +0200
1"""CLI entry point for Truenex Memory."""
3from dataclasses import asdict
4from pathlib import Path
5import json
6import sys
8import typer
10from truenex_memory import __version__
11from truenex_memory.adapters.agents_md import generate_agents_md
12from truenex_memory.adapters.claude_md import generate_claude_md
13from truenex_memory.core.memory_service import MemoryService
14from truenex_memory.diagnostics.doctor import run_doctor
15from truenex_memory.export.exporter import export_memory
16from truenex_memory.export.importer import import_memory
17from truenex_memory.mcp.server import run_stdio_server
18from truenex_memory.release.manifest import DEFAULT_MANIFEST_URL
19from truenex_memory.release.update_check import check_for_updates
20from truenex_memory.release.version import get_version_info
21from truenex_memory.core.config import resolve_project_config
22from truenex_memory.core.migration import list_backups
23from truenex_memory.core.migration import migrate_apply as apply_migrations
24from truenex_memory.core.migration import migration_status
25from truenex_memory.core.migration import restore_backup
26from truenex_memory.discovery.agent_discovery import (
27 DEFAULT_DISPLAY_LIMIT,
28 discover_from_agents,
29 format_report,
30)
31from truenex_memory.discovery.source_catalog import (
32 CatalogEntry,
33 SourceCatalog,
34 default_catalog_path,
35 entries_to_dict,
36 format_entries,
37 report_to_entries,
38 source_id,
39)
40from truenex_memory.ingestion.engine import ingest_manifest
41from truenex_memory.ingestion.global_refresh import (
42 RefreshReport,
43 format_refresh_report,
44 refresh as run_global_refresh,
45)
46from truenex_memory.ingestion.global_context import (
47 build_project_context,
48 format_context_report,
49)
50from truenex_memory.ingestion.global_search import (
51 DEFAULT_GLOBAL_SEARCH_LIMIT,
52 GLOBAL_SEARCH_KINDS,
53 build_global_search,
54 format_global_search_report,
55)
56from truenex_memory.ingestion.global_status import (
57 build_global_status,
58 format_status_report,
59)
60from truenex_memory.ingestion.global_source_health import (
61 build_source_health,
62 format_source_health_report,
63)
64from truenex_memory.ingestion.global_auto_status import (
65 build_auto_status,
66 format_auto_status_report,
67)
68from truenex_memory.ingestion.global_auto_review import (
69 DEFAULT_CONTENT_CHARS,
70 DEFAULT_REVIEW_LIMIT,
71 build_auto_memory_review,
72 format_auto_memory_review,
73)
74from truenex_memory.ingestion.global_auto_lifecycle import (
75 CURATED_AUTO_MEMORY_TYPES,
76 DEFAULT_PRUNE_LIMIT,
77 approve_auto_memory,
78 format_auto_memory_lifecycle_report,
79 promote_auto_memory,
80 prune_auto_memories,
81 reject_auto_memory,
82)
83from truenex_memory.ingestion.global_auto_memory import (
84 DEFAULT_AUTO_MEMORY_LIMIT,
85 DEFAULT_AUTO_MEMORY_PER_SOURCE_LIMIT,
86 DEFAULT_CONFIDENCE,
87 generate_unverified_auto_memories,
88)
89from truenex_memory.retrieval.result import search_payload
90from truenex_memory.store.models import VALID_STATUSES
91from truenex_memory.cli.task_commands import task_app
93app = typer.Typer(
94 name="truenex-mem",
95 help="Local-first memory layer for coding agents.",
96)
97adapter_app = typer.Typer(help="Generate local agent adapter files.")
98update_app = typer.Typer(help="Manual update checks.")
99migrate_app = typer.Typer(help="Schema migration management.")
100status_app = typer.Typer(help="Manage memory node lifecycle status.")
101ingest_app = typer.Typer(help="Ingest external sources from a manifest.")
102trace_app = typer.Typer(help="Inspect retrieval trace logs.")
103global_app = typer.Typer(help="Global store operations (discovery, refresh, status).")
104sources_app = typer.Typer(help="Review, confirm, and add source catalog entries.")
105auto_app = typer.Typer(help="Automatic memory maintenance (Phase 3).")
106app.add_typer(adapter_app, name="adapter")
107app.add_typer(update_app, name="update")
108app.add_typer(migrate_app, name="migrate")
109app.add_typer(status_app, name="status")
110app.add_typer(ingest_app, name="ingest")
111app.add_typer(trace_app, name="trace")
112global_app.add_typer(sources_app, name="sources")
113global_app.add_typer(auto_app, name="auto")
114app.add_typer(global_app, name="global")
115app.add_typer(task_app, name="task")
118@app.callback()
119def callback() -> None:
120 """Truenex Memory - local-first memory for coding agents."""
121 for stream in (sys.stdout, sys.stderr):
122 if hasattr(stream, "reconfigure"):
123 stream.reconfigure(encoding="utf-8", errors="replace")
126@app.command()
127def version() -> None:
128 """Print the Truenex Memory version."""
129 print(f"truenex-mem {__version__}")
132@app.command("version-info")
133def version_info() -> None:
134 """Print all Truenex Memory component versions as JSON."""
136 typer.echo(json.dumps(get_version_info(), indent=2, sort_keys=True))
139@app.command()
140def init() -> None:
141 """Initialize local project memory storage."""
143 service = MemoryService(".")
144 service.init_project()
145 typer.echo(f"Initialized {service.config.data_dir}")
148@app.command()
149def add(
150 content: str = typer.Argument(..., help="Memory content to store."),
151 memory_type: str = typer.Option("note", "--type", help="Memory type, e.g. note or decision."),
152) -> None:
153 """Add a manual memory node."""
155 memory_id = MemoryService(".").add(content, memory_type=memory_type)
156 typer.echo(memory_id)
159@app.command("list")
160def list_command(
161 status: str | None = typer.Option(None, "--status", help="Filter by lifecycle status."),
162 json_output: bool = typer.Option(False, "--json", help="Print JSON output."),
163) -> None:
164 """List manual memory nodes."""
166 if status is not None:
167 _validate_status(status)
168 memories = MemoryService(".").list_memory_nodes(status=status)
169 if json_output:
170 typer.echo(json.dumps([asdict(memory) for memory in memories], indent=2, sort_keys=True))
171 return
172 for memory in memories:
173 typer.echo(f"{memory.id} {memory.status} {memory.type} {memory.title}")
176@app.command()
177def index(path: Path = typer.Argument(Path("."), help="File or directory to index.")) -> None:
178 """Index local files into the project memory store."""
180 if not path.exists():
181 raise typer.BadParameter(f"path does not exist: {path}")
182 count = MemoryService(".").index(path)
183 typer.echo(f"Indexed {count} file(s)")
186@app.command()
187def search(
188 query: str = typer.Argument(..., help="Search query."),
189 top_k: int = typer.Option(5, "--top-k", min=1, max=50, help="Maximum results."),
190 json_output: bool = typer.Option(False, "--json", help="Print full JSON payload."),
191 include_inactive: bool = typer.Option(
192 False, "--include-inactive", help="Include inactive (e.g. obsolete) memories in results."
193 ),
194) -> None:
195 """Search local memory."""
197 service = MemoryService(".")
198 results = service.search(query, top_k=top_k, include_inactive=include_inactive)
199 payload = search_payload(query, results, trace_id=service.last_trace_id)
200 if json_output:
201 typer.echo(json.dumps(payload, indent=2, sort_keys=True))
202 return
203 for item in payload["results"]:
204 typer.echo(f"{item['score']:.4f} {item['title']} [{item['memory_type']}/{item['status']}]")
205 if item["source_path"]:
206 typer.echo(f" source: {item['source_path']}")
207 typer.echo(f" {item['content']}")
210@app.command("logs")
211def logs_command(
212 limit: int = typer.Option(20, "--limit", "-n", min=1, max=100, help="Number of recent logs."),
213 json_output: bool = typer.Option(False, "--json", help="Print JSON output."),
214) -> None:
215 """List recent retrieval trace logs."""
217 service = MemoryService(".")
218 logs = service.list_retrieval_logs(limit=limit)
219 if json_output:
220 items = [
221 {
222 "id": log.id,
223 "trace_id": log.id,
224 "query": log.query,
225 "result_count": log.result_count,
226 "top_k": log.top_k,
227 "created_at": log.created_at,
228 }
229 for log in logs
230 ]
231 typer.echo(json.dumps(items, indent=2, sort_keys=True))
232 return
233 if not logs:
234 typer.echo("No retrieval logs found.")
235 return
236 for log in logs:
237 typer.echo(f"{log.id} | {log.result_count}/{log.top_k} | {log.query}")
240@trace_app.command("show")
241def trace_show(
242 trace_id: str = typer.Argument(..., help="Trace ID from a search or logs command."),
243 json_output: bool = typer.Option(False, "--json", help="Print full JSON payload."),
244) -> None:
245 """Show a retrieval trace by ID with full result details."""
247 service = MemoryService(".")
248 log = service.get_retrieval_log(trace_id)
249 if log is None:
250 raise typer.BadParameter(f"trace not found: {trace_id!r}")
251 payload = {
252 "trace_id": log.id,
253 "query": log.query,
254 "top_k": log.top_k,
255 "result_count": log.result_count,
256 "created_at": log.created_at,
257 "results": log.parsed_results(),
258 }
259 if json_output:
260 typer.echo(json.dumps(payload, indent=2, sort_keys=True))
261 return
262 typer.echo(f"Trace: {log.id}")
263 typer.echo(f"Query: {log.query}")
264 typer.echo(f"Results: {log.result_count}/{log.top_k} | {log.created_at}")
265 typer.echo("")
266 results = log.parsed_results()
267 for item in results:
268 typer.echo(
269 f"{item.get('score', 0):.4f} {item.get('title', '?')} "
270 f"[{item.get('memory_type', '?')}/{item.get('status', '?')}]"
271 )
272 if item.get("source_path"):
273 typer.echo(f" source: {item['source_path']}")
274 if item.get("heading_path"):
275 typer.echo(f" heading: {item['heading_path']}")
276 typer.echo(f" {item.get('content', '')}")
279@migrate_app.command("status")
280def migrate_status(
281 json_output: bool = typer.Option(False, "--json", help="Print JSON output."),
282) -> None:
283 """Show current and latest schema versions."""
284 config = resolve_project_config(".")
285 status = migration_status(config.db_path)
287 if json_output:
288 typer.echo(json.dumps(status, indent=2, sort_keys=True))
289 return
291 typer.echo(f"Current schema version: {status['current_version']}")
292 typer.echo(f"Latest schema version: {status['latest_version']}")
293 typer.echo("Status: migrations pending" if status["pending"] else "Status: up to date")
296@migrate_app.command("apply")
297def migrate_apply(
298 json_output: bool = typer.Option(False, "--json", help="Print JSON output."),
299) -> None:
300 """Apply pending schema migrations (with automatic pre-migration backup)."""
301 config = resolve_project_config(".")
302 result = apply_migrations(config.db_path, config.backups_dir)
304 if json_output:
305 payload = {k: v for k, v in result.items()}
306 typer.echo(json.dumps(payload, indent=2, sort_keys=True))
307 return
309 if not result["applied"]:
310 typer.echo("Already up to date, no migrations applied.")
311 return
313 typer.echo(f"Applied migrations: {result['previous_version']} -> {result['current_version']}")
314 if result["backup_path"]:
315 typer.echo(f"Backup created at: {result['backup_path']}")
318@migrate_app.command("backup-list")
319def migrate_backup_list(
320 json_output: bool = typer.Option(False, "--json", help="Print JSON output."),
321) -> None:
322 """List available migration backups (newest first)."""
323 config = resolve_project_config(".")
324 backups = list_backups(config.backups_dir)
326 if json_output:
327 typer.echo(json.dumps(backups, indent=2, sort_keys=True))
328 return
330 if not backups:
331 typer.echo("No migration backups found.")
332 return
334 for entry in backups:
335 size_kb = int(entry["size_bytes"]) / 1024 # type: ignore[arg-type]
336 typer.echo(
337 f"{entry['filename']} {size_kb:.1f} KiB {entry['created']}"
338 )
341@migrate_app.command("restore")
342def migrate_restore(
343 backup_filename: str = typer.Argument(..., help="Backup filename to restore."),
344 json_output: bool = typer.Option(False, "--json", help="Print JSON output."),
345) -> None:
346 """Restore a migration backup to the active database.
348 A safety backup of the current database is created before overwriting.
349 """
350 config = resolve_project_config(".")
351 try:
352 result = restore_backup(config.db_path, config.backups_dir, backup_filename)
353 except (ValueError, FileNotFoundError, RuntimeError) as exc:
354 if json_output:
355 typer.echo(
356 json.dumps({"error": str(exc)}, indent=2, sort_keys=True)
357 )
358 raise typer.Exit(code=1)
359 raise typer.BadParameter(str(exc)) from exc
361 if json_output:
362 typer.echo(json.dumps(result, indent=2, sort_keys=True))
363 return
365 typer.echo(f"Restored: {result['backup_filename']}")
366 typer.echo(f"Current schema version: {result['current_version']}")
367 if result["safety_backup_path"]:
368 typer.echo(f"Safety backup: {result['safety_backup_path']}")
371@app.command()
372def doctor(privacy: bool = typer.Option(False, "--privacy", help="Include privacy diagnostics.")) -> None:
373 """Run local diagnostics."""
375 typer.echo(json.dumps(run_doctor(".", privacy=privacy), indent=2, sort_keys=True))
378@app.command("export")
379def export_command(output: Path = typer.Option(..., "--output", "-o", help="Output JSON file.")) -> None:
380 """Export local memory data."""
382 exported = export_memory(output, project_root=".")
383 typer.echo(f"Exported {exported}")
386@app.command("import")
387def import_command(input_path: Path = typer.Argument(..., help="Memory export JSON file.")) -> None:
388 """Import local memory data."""
390 import_memory(input_path, project_root=".")
391 typer.echo(f"Imported {input_path}")
394@app.command()
395def mcp(
396 project_root: Path = typer.Option(
397 Path("."),
398 "--project-root",
399 help="Project root used for local memory storage.",
400 ),
401) -> None:
402 """Run the local stdio memory tool server."""
404 run_stdio_server(project_root=project_root)
407@status_app.command("set")
408def status_set(
409 memory_id: str = typer.Argument(..., help="Memory node id."),
410 status: str = typer.Argument(..., help="New lifecycle status."),
411) -> None:
412 """Set a memory node lifecycle status."""
414 _validate_status(status)
415 try:
416 MemoryService(".").set_memory_status(memory_id, status)
417 except LookupError as exc:
418 raise typer.BadParameter(str(exc)) from exc
419 typer.echo(f"Updated {memory_id} -> {status}")
422@adapter_app.command("agents-md")
423def adapter_agents_md() -> None:
424 """Print AGENTS.md instructions."""
426 typer.echo(generate_agents_md())
429@adapter_app.command("claude-md")
430def adapter_claude_md() -> None:
431 """Print CLAUDE.md instructions."""
433 typer.echo(generate_claude_md())
436@update_app.command("check")
437def update_check(
438 manifest_url: str = typer.Option(
439 DEFAULT_MANIFEST_URL,
440 "--manifest-url",
441 help="Public JSON manifest URL.",
442 ),
443) -> None:
444 """Check for updates without sending project data."""
446 result = check_for_updates(manifest_url=manifest_url)
447 typer.echo(json.dumps(result.to_dict(), indent=2, sort_keys=True))
450@ingest_app.command("manifest")
451def ingest_manifest_command(
452 manifest: Path = typer.Option(
453 ...,
454 "--manifest",
455 "-m",
456 help="Path to the source manifest JSON file.",
457 exists=True,
458 dir_okay=False,
459 readable=True,
460 ),
461 dry_run: bool = typer.Option(False, "--dry-run", help="Validate and report without indexing."),
462 json_output: bool = typer.Option(False, "--json", help="Print report as JSON."),
463 project_root: Path = typer.Option(
464 Path("."),
465 "--project-root",
466 help="Project root for memory storage and relative path resolution.",
467 ),
468) -> None:
469 """Ingest sources declared in a manifest file.
471 The manifest is a JSON file listing sources with source_type, source_path,
472 and optional source_tool / privacy_scope fields.
474 Supported source_type values:
475 project_docs - text project files (md, py, toml, etc.)
476 agent_session - Codex/Claude-style JSONL session logs
478 Future (parse_later):
479 agent_memory, operations_note, binary_document
481 Dry-run reports which sources would be indexed, deferred, skipped, or in
482 error without modifying the database.
483 """
484 service = MemoryService(project_root)
485 if not dry_run:
486 service.init_project()
488 report = ingest_manifest(
489 manifest_path=manifest.resolve(),
490 project_root=service.config.project_root,
491 repository=service.repository,
492 dry_run=dry_run,
493 )
495 if json_output:
496 typer.echo(json.dumps(report, indent=2, sort_keys=True))
497 return
499 _print_ingest_report(report, dry_run)
502@global_app.command("discover")
503def global_discover(
504 from_agents: bool = typer.Option(
505 True, "--from-agents", help="Discover from local agent client directories."
506 ),
507 home: Path = typer.Option(
508 Path.home(),
509 "--home",
510 help="User home directory containing .codex / .claude agent roots.",
511 ),
512 json_output: bool = typer.Option(False, "--json", help="Print report as JSON."),
513 output: Path | None = typer.Option(
514 None, "--output", "-o", help="Write report to this file (JSON or .md)."
515 ),
516 limit: int | None = typer.Option(
517 None,
518 "--limit",
519 "-n",
520 min=1,
521 max=500,
522 help="Max entries per section (default 20 for text, unlimited for JSON).",
523 ),
524) -> None:
525 """Discover projects, docs, and servers from local agent clients.
527 Scans Codex (.codex/sessions, .codex/memories) and Claude
528 (.claude/projects, .claude/commands) directories to find:
529 - Candidate project paths
530 - Document references
531 - SSH/server aliases
533 This is discovery only -- it does not modify the memory database.
534 """
535 if not from_agents:
536 typer.echo("Currently only --from-agents discovery is supported.")
537 raise typer.Exit(code=2)
539 report = discover_from_agents(home)
541 if output is not None:
542 suffix = output.suffix.lower()
543 if suffix == ".json":
544 d = report.to_dict()
545 if limit is not None:
546 d["projects"] = d["projects"][:limit]
547 d["documents"] = d["documents"][:limit]
548 d["servers"] = d["servers"][:limit]
549 output.write_text(
550 json.dumps(d, indent=2, sort_keys=True),
551 encoding="utf-8",
552 )
553 else:
554 display_limit = limit if limit is not None else DEFAULT_DISPLAY_LIMIT
555 output.write_text(format_report(report, limit=display_limit), encoding="utf-8")
556 typer.echo(f"Report written to {output}")
557 return
559 if json_output:
560 d = report.to_dict()
561 if limit is not None:
562 d["projects"] = d["projects"][:limit]
563 d["documents"] = d["documents"][:limit]
564 d["servers"] = d["servers"][:limit]
565 typer.echo(json.dumps(d, indent=2, sort_keys=True))
566 else:
567 display_limit = limit if limit is not None else DEFAULT_DISPLAY_LIMIT
568 typer.echo(format_report(report, limit=display_limit))
571@sources_app.command("review")
572def sources_review(
573 home: Path = typer.Option(
574 Path.home(),
575 "--home",
576 help="User home directory containing .codex / .claude agent roots.",
577 ),
578 json_output: bool = typer.Option(False, "--json", help="Print entries as JSON."),
579 limit: int | None = typer.Option(
580 None,
581 "--limit",
582 "-n",
583 min=1,
584 max=500,
585 help="Max entries per section (default 20 for text, unlimited for JSON).",
586 ),
587 include: list[str] | None = typer.Option(
588 None,
589 "--include",
590 "-i",
591 help="Keep entries whose id/path/project/source contains any of these texts. Repeatable.",
592 ),
593 exclude: list[str] | None = typer.Option(
594 None,
595 "--exclude",
596 "-x",
597 help="Drop entries whose id/path/project/source contains this text. Repeatable.",
598 ),
599 source_type: list[str] | None = typer.Option(
600 None,
601 "--source-type",
602 help="Keep only entries of this source type. Repeatable.",
603 ),
604) -> None:
605 """Review discovered source candidates without writing the catalog.
607 Runs discovery from agent roots and prints candidate catalog entries.
608 No files or databases are mutated.
609 """
610 report = discover_from_agents(home)
611 effective_limit = limit if limit is not None else (None if json_output else DEFAULT_DISPLAY_LIMIT)
612 entries = report_to_entries(
613 report,
614 limit=effective_limit,
615 confirmation_status="candidate",
616 )
617 entries = _filter_catalog_entries(
618 entries,
619 include=include,
620 exclude=exclude,
621 source_type=source_type,
622 )
624 if json_output:
625 typer.echo(json.dumps(entries_to_dict(entries), indent=2, sort_keys=True))
626 else:
627 typer.echo(format_entries(entries))
630@sources_app.command("confirm")
631def sources_confirm(
632 home: Path = typer.Option(
633 Path.home(),
634 "--home",
635 help="User home directory containing .codex / .claude agent roots.",
636 ),
637 catalog: Path | None = typer.Option(
638 None,
639 "--catalog",
640 help="Path to the source catalog JSON file (default: <home>/.truenex-memory/sources.json).",
641 ),
642 limit: int | None = typer.Option(
643 DEFAULT_DISPLAY_LIMIT,
644 "--limit",
645 "-n",
646 min=1,
647 max=500,
648 help="Max entries per section to confirm.",
649 ),
650 yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
651 json_output: bool = typer.Option(False, "--json", help="Print entries as JSON."),
652 include: list[str] | None = typer.Option(
653 None,
654 "--include",
655 "-i",
656 help="Keep entries whose id/path/project/source contains any of these texts. Repeatable.",
657 ),
658 exclude: list[str] | None = typer.Option(
659 None,
660 "--exclude",
661 "-x",
662 help="Drop entries whose id/path/project/source contains this text. Repeatable.",
663 ),
664 source_type: list[str] | None = typer.Option(
665 None,
666 "--source-type",
667 help="Keep only entries of this source type. Repeatable.",
668 ),
669) -> None:
670 """Confirm discovered sources and write the catalog.
672 Runs discovery from agent roots, converts candidates to catalog entries,
673 and writes confirmed entries to the catalog JSON file.
675 By default only the top-ranked subset per section is confirmed.
676 Use --limit to adjust the count or pass a large value to confirm more.
677 """
678 report = discover_from_agents(home)
679 entries = report_to_entries(report, limit=limit, confirmation_status="confirmed")
680 entries = _filter_catalog_entries(
681 entries,
682 include=include,
683 exclude=exclude,
684 source_type=source_type,
685 )
686 catalog_path = catalog if catalog is not None else default_catalog_path(home)
688 if json_output:
689 typer.echo(json.dumps(entries_to_dict(entries), indent=2, sort_keys=True))
691 if not yes:
692 count = len(entries)
693 prompt_text = f"Confirm writing {count} entries to {catalog_path}? [y/N] "
694 try:
695 answer = input(prompt_text).strip().lower()
696 except (EOFError, KeyboardInterrupt):
697 typer.echo("Aborted.")
698 raise typer.Exit(code=1)
699 if answer not in ("y", "yes"):
700 typer.echo("Aborted.")
701 raise typer.Exit(code=1)
703 sc = SourceCatalog(entries=entries)
704 sc.save(catalog_path)
705 typer.echo(f"Catalog written: {len(entries)} entries to {catalog_path}")
708_VALID_SOURCE_TYPES = frozenset({"agent_root", "project_root", "document", "server_alias"})
711@sources_app.command("add")
712def sources_add(
713 home: Path = typer.Option(
714 Path.home(),
715 "--home",
716 help="User home directory for default catalog path.",
717 ),
718 catalog: Path | None = typer.Option(
719 None,
720 "--catalog",
721 help="Path to the source catalog JSON file (default: <home>/.truenex-memory/sources.json).",
722 ),
723 source_type: str = typer.Option(
724 ...,
725 "--source-type",
726 help="Source type: agent_root, project_root, document, or server_alias.",
727 ),
728 path_or_alias: str = typer.Option(
729 ...,
730 "--path-or-alias",
731 help="Filesystem path (agent_root/project_root/document) or server alias.",
732 ),
733 project_name: str | None = typer.Option(
734 None,
735 "--project-name",
736 help="Human-readable project name (optional).",
737 ),
738 discovered_from: list[str] | None = typer.Option(
739 None,
740 "--discovered-from",
741 help="Agent root label(s) this source was discovered from. Repeatable.",
742 ),
743 confidence: float = typer.Option(
744 0.0,
745 "--confidence",
746 min=0.0,
747 help="Discovery confidence score.",
748 ),
749 evidence_count: int = typer.Option(
750 0,
751 "--evidence-count",
752 min=0,
753 help="Number of discovery evidence items.",
754 ),
755 yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
756 json_output: bool = typer.Option(False, "--json", help="Print result as JSON."),
757) -> None:
758 """Add or update a single confirmed source in the catalog.
760 Computes a stable id from --source-type and --path-or-alias, then
761 inserts or replaces the matching entry. Existing entries with
762 different ids are preserved unchanged.
763 """
764 if source_type not in _VALID_SOURCE_TYPES:
765 valid = ", ".join(sorted(_VALID_SOURCE_TYPES))
766 raise typer.BadParameter(f"invalid source-type {source_type!r}; expected one of {valid}")
768 catalog_path = catalog if catalog is not None else default_catalog_path(home)
769 sc = SourceCatalog.load(catalog_path)
771 entry = CatalogEntry(
772 id=source_id(source_type, path_or_alias),
773 source_type=source_type,
774 path_or_alias=path_or_alias,
775 project_name=project_name,
776 discovered_from=list(discovered_from or []),
777 confirmation_status="confirmed",
778 privacy_scope="local-private",
779 confidence=confidence,
780 evidence_count=evidence_count,
781 )
783 action, _ = sc.upsert_entry(entry)
784 total = len(sc.entries)
786 if not yes:
787 verb = "Update" if action == "updated" else "Add"
788 desc = f"{entry.source_type}:{entry.path_or_alias}"
789 if entry.project_name:
790 desc += f" [{entry.project_name}]"
791 try:
792 typer.echo(f"{verb} {desc} in {catalog_path}? [y/N] ", nl=False, err=json_output)
793 answer = input().strip().lower()
794 except (EOFError, KeyboardInterrupt):
795 typer.echo("Aborted.", err=json_output)
796 raise typer.Exit(code=1)
797 if answer not in ("y", "yes"):
798 typer.echo("Aborted.", err=json_output)
799 raise typer.Exit(code=1)
801 sc.save(catalog_path)
802 if json_output:
803 typer.echo(json.dumps({
804 "action": action,
805 "entry": asdict(entry),
806 "catalog_path": str(catalog_path),
807 "total_entries": total,
808 }, indent=2, sort_keys=True))
809 return
811 typer.echo(f"{'Updated' if action == 'updated' else 'Added'}: "
812 f"{entry.source_type}:{entry.path_or_alias} "
813 f"to {catalog_path} (total: {total} entries)")
816@sources_app.command("health")
817def sources_health(
818 home: Path = typer.Option(
819 Path.home(),
820 "--home",
821 help="User home directory for default paths.",
822 ),
823 catalog: Path | None = typer.Option(
824 None,
825 "--catalog",
826 help="Path to the source catalog JSON file (default: <home>/.truenex-memory/sources.json).",
827 ),
828 db: Path | None = typer.Option(
829 None,
830 "--db",
831 help="Path to the SQLite database (default: <home>/.truenex-memory/truenex_memory.db).",
832 ),
833 limit: int = typer.Option(
834 50,
835 "--limit",
836 min=1,
837 max=500,
838 help="Max action rows to show.",
839 ),
840 json_output: bool = typer.Option(False, "--json", help="Print report as JSON."),
841) -> None:
842 """Review source catalog and ledger health without writing anything."""
843 catalog_path = catalog if catalog is not None else default_catalog_path(home)
844 db_path = db if db is not None else home / ".truenex-memory" / "truenex_memory.db"
845 report = build_source_health(catalog_path, db_path, apply=False, limit=limit)
847 if json_output:
848 typer.echo(json.dumps(report.to_dict(), indent=2, sort_keys=True))
849 else:
850 typer.echo(format_source_health_report(report))
853@sources_app.command("cleanup")
854def sources_cleanup(
855 home: Path = typer.Option(
856 Path.home(),
857 "--home",
858 help="User home directory for default paths.",
859 ),
860 catalog: Path | None = typer.Option(
861 None,
862 "--catalog",
863 help="Path to the source catalog JSON file (default: <home>/.truenex-memory/sources.json).",
864 ),
865 db: Path | None = typer.Option(
866 None,
867 "--db",
868 help="Path to the SQLite database (default: <home>/.truenex-memory/truenex_memory.db).",
869 ),
870 yes: bool = typer.Option(False, "--yes", "-y", help="Apply cleanup changes."),
871 limit: int = typer.Option(
872 50,
873 "--limit",
874 min=1,
875 max=500,
876 help="Max action rows to show.",
877 ),
878 json_output: bool = typer.Option(False, "--json", help="Print report as JSON."),
879) -> None:
880 """Clean source catalog/ledger health issues.
882 Dry-run by default. With --yes, missing local catalog entries are disabled
883 and expected ledger problems are marked skipped. No indexed chunks or
884 memory nodes are deleted.
885 """
886 catalog_path = catalog if catalog is not None else default_catalog_path(home)
887 db_path = db if db is not None else home / ".truenex-memory" / "truenex_memory.db"
888 report = build_source_health(catalog_path, db_path, apply=yes, limit=limit)
890 if json_output:
891 typer.echo(json.dumps(report.to_dict(), indent=2, sort_keys=True))
892 else:
893 typer.echo(format_source_health_report(report))
894 if not yes:
895 typer.echo("\n(dry-run, pass --yes to apply cleanup)")
898def _filter_catalog_entries(
899 entries: list[object],
900 *,
901 include: list[str] | None,
902 exclude: list[str] | None,
903 source_type: list[str] | None,
904) -> list[object]:
905 """Filter catalog entries with case-insensitive CLI semantics.
907 Repeated includes keep entries matching any term. Repeated excludes drop
908 entries matching any term, so exclude wins when an entry matches both.
909 Source types must match exactly after lowercasing.
910 """
911 includes = [item.lower() for item in (include or []) if item.strip()]
912 excludes = [item.lower() for item in (exclude or []) if item.strip()]
913 source_types = {item.lower() for item in (source_type or []) if item.strip()}
914 if not includes and not excludes and not source_types:
915 return entries
917 filtered: list[object] = []
918 for entry in entries:
919 haystack = _catalog_entry_search_text(entry)
920 entry_source_type = str(getattr(entry, "source_type", "")).lower()
921 if source_types and entry_source_type not in source_types:
922 continue
923 if includes and not any(term in haystack for term in includes):
924 continue
925 if excludes and any(term in haystack for term in excludes):
926 continue
927 filtered.append(entry)
928 return filtered
931def _catalog_entry_search_text(entry: object) -> str:
932 parts = [
933 getattr(entry, "id", ""),
934 getattr(entry, "source_type", ""),
935 getattr(entry, "path_or_alias", ""),
936 getattr(entry, "project_name", "") or "",
937 getattr(entry, "privacy_scope", ""),
938 ]
939 discovered = getattr(entry, "discovered_from", [])
940 if isinstance(discovered, list):
941 parts.extend(str(item) for item in discovered)
942 return " ".join(str(part) for part in parts).lower()
945@global_app.command("refresh")
946def global_refresh(
947 home: Path = typer.Option(
948 Path.home(),
949 "--home",
950 help="User home directory for default paths.",
951 ),
952 catalog: Path | None = typer.Option(
953 None,
954 "--catalog",
955 help="Path to the source catalog JSON file (default: <home>/.truenex-memory/sources.json).",
956 ),
957 db: Path | None = typer.Option(
958 None,
959 "--db",
960 help="Path to the SQLite database (default: <home>/.truenex-memory/truenex_memory.db).",
961 ),
962 dry_run: bool = typer.Option(
963 False,
964 "--dry-run",
965 help="Report planned actions without modifying DB/ledger.",
966 ),
967 json_output: bool = typer.Option(False, "--json", help="Print report as JSON."),
968 detail_limit: int = typer.Option(
969 200,
970 "--detail-limit",
971 min=0,
972 help="Maximum per-source detail rows in JSON output; use 0 for no details.",
973 ),
974 full_details: bool = typer.Option(
975 False,
976 "--full-details",
977 help="Include all per-source detail rows in JSON output.",
978 ),
979 stability_seconds: int = typer.Option(
980 120,
981 "--stability-seconds",
982 min=0,
983 help="Skip .jsonl files modified within this many seconds (default 120).",
984 ),
985) -> None:
986 """Run incremental global refresh from confirmed source catalog.
988 Loads confirmed sources from the catalog, runs parsers, checks the
989 source ledger for changes, and indexes only new or modified content.
990 """
991 catalog_path = catalog if catalog is not None else default_catalog_path(home)
992 db_path = db if db is not None else home / ".truenex-memory" / "truenex_memory.db"
994 if not catalog_path.exists():
995 if json_output:
996 typer.echo(
997 json.dumps(
998 {"error": f"Catalog file not found: {catalog_path}"},
999 indent=2,
1000 sort_keys=True,
1001 )
1002 )
1003 else:
1004 typer.echo(f"Error: Catalog file not found: {catalog_path}")
1005 raise typer.Exit(code=1)
1007 report = run_global_refresh(
1008 catalog_path=catalog_path,
1009 db_path=db_path,
1010 dry_run=dry_run,
1011 stability_seconds=stability_seconds,
1012 )
1013 if json_output:
1014 limit = None if full_details else detail_limit
1015 typer.echo(json.dumps(report.to_dict(detail_limit=limit), indent=2, sort_keys=True))
1016 else:
1017 typer.echo(format_refresh_report(report))
1018 if dry_run:
1019 typer.echo("\n(dry-run, DB/ledger unchanged)")
1022@global_app.command("status")
1023def global_status(
1024 home: Path = typer.Option(
1025 Path.home(),
1026 "--home",
1027 help="User home directory for default paths.",
1028 ),
1029 catalog: Path | None = typer.Option(
1030 None,
1031 "--catalog",
1032 help="Path to the source catalog JSON file (default: <home>/.truenex-memory/sources.json).",
1033 ),
1034 db: Path | None = typer.Option(
1035 None,
1036 "--db",
1037 help="Path to the SQLite database (default: <home>/.truenex-memory/truenex_memory.db).",
1038 ),
1039 json_output: bool = typer.Option(False, "--json", help="Print report as JSON."),
1040) -> None:
1041 """Show read-only global store status (catalog, ledger, indexed, problems).
1043 This command never creates directories, databases, catalog files, or
1044 ledger rows. It only reports on what already exists.
1045 """
1046 catalog_path = catalog if catalog is not None else default_catalog_path(home)
1047 db_path = db if db is not None else home / ".truenex-memory" / "truenex_memory.db"
1049 report = build_global_status(catalog_path=catalog_path, db_path=db_path)
1051 if json_output:
1052 typer.echo(json.dumps(report.to_dict(), indent=2, sort_keys=True))
1053 else:
1054 typer.echo(format_status_report(report))
1057@global_app.command("context")
1058def global_context(
1059 project: str = typer.Argument(..., help="Project name, basename, or path alias to look up."),
1060 home: Path = typer.Option(
1061 Path.home(),
1062 "--home",
1063 help="User home directory for default paths.",
1064 ),
1065 catalog: Path | None = typer.Option(
1066 None,
1067 "--catalog",
1068 help="Path to the source catalog JSON file (default: <home>/.truenex-memory/sources.json).",
1069 ),
1070 db: Path | None = typer.Option(
1071 None,
1072 "--db",
1073 help="Path to the SQLite database (default: <home>/.truenex-memory/truenex_memory.db).",
1074 ),
1075 json_output: bool = typer.Option(False, "--json", help="Print report as JSON."),
1076 limit: int = typer.Option(
1077 20,
1078 "--limit",
1079 min=1,
1080 help="Max source/chunk excerpts (default 20).",
1081 ),
1082) -> None:
1083 """Show read-only context for a confirmed project from the global store.
1085 Resolves the project from the confirmed source catalog and reads the
1086 SQLite DB/ledger/index without mutating anything. Server aliases are
1087 reported as hints only and never executed.
1089 This command never creates directories, databases, catalog files, or
1090 ledger rows.
1091 """
1092 catalog_path = catalog if catalog is not None else default_catalog_path(home)
1093 db_path = db if db is not None else home / ".truenex-memory" / "truenex_memory.db"
1095 report = build_project_context(
1096 project_query=project,
1097 catalog_path=catalog_path,
1098 db_path=db_path,
1099 limit=limit,
1100 )
1102 if json_output:
1103 typer.echo(json.dumps(report.to_dict(), indent=2, sort_keys=True))
1104 else:
1105 typer.echo(format_context_report(report))
1108@global_app.command("search")
1109def global_search(
1110 query: str = typer.Argument(..., help="Search query for the global store."),
1111 home: Path = typer.Option(
1112 Path.home(),
1113 "--home",
1114 help="User home directory for default paths.",
1115 ),
1116 db: Path | None = typer.Option(
1117 None,
1118 "--db",
1119 help="Path to the SQLite database (default: <home>/.truenex-memory/truenex_memory.db).",
1120 ),
1121 top_k: int = typer.Option(
1122 DEFAULT_GLOBAL_SEARCH_LIMIT,
1123 "--top-k",
1124 min=1,
1125 max=50,
1126 help="Maximum global search results.",
1127 ),
1128 kind: str = typer.Option(
1129 "all",
1130 "--kind",
1131 help="Search result kind: all, memory, or chunks.",
1132 ),
1133 include_inactive: bool = typer.Option(
1134 False,
1135 "--include-inactive",
1136 help="Include inactive memory statuses such as obsolete or superseded.",
1137 ),
1138 json_output: bool = typer.Option(False, "--json", help="Print report as JSON."),
1139) -> None:
1140 """Search the global store without mutating retrieval logs or DB state."""
1141 if kind not in GLOBAL_SEARCH_KINDS:
1142 expected = ", ".join(sorted(GLOBAL_SEARCH_KINDS))
1143 raise typer.BadParameter(f"invalid kind {kind!r}; expected one of {expected}")
1144 db_path = db if db is not None else home / ".truenex-memory" / "truenex_memory.db"
1145 report = build_global_search(
1146 db_path=db_path,
1147 query=query,
1148 top_k=top_k,
1149 include_inactive=include_inactive,
1150 kind_filter=kind,
1151 )
1153 if json_output:
1154 typer.echo(json.dumps(report.to_dict(), indent=2, sort_keys=True))
1155 else:
1156 typer.echo(format_global_search_report(report))
1159@auto_app.command("run")
1160def auto_run(
1161 home: Path = typer.Option(
1162 Path.home(),
1163 "--home",
1164 help="User home directory for default paths.",
1165 ),
1166 catalog: Path | None = typer.Option(
1167 None,
1168 "--catalog",
1169 help="Path to the source catalog JSON file (default: <home>/.truenex-memory/sources.json).",
1170 ),
1171 db: Path | None = typer.Option(
1172 None,
1173 "--db",
1174 help="Path to the SQLite database (default: <home>/.truenex-memory/truenex_memory.db).",
1175 ),
1176 dry_run: bool = typer.Option(
1177 False,
1178 "--dry-run",
1179 help="Report planned actions without modifying DB/ledger.",
1180 ),
1181 skip_refresh: bool = typer.Option(
1182 False,
1183 "--skip-refresh",
1184 help="Use existing indexed DB only; do not parse catalog or source files.",
1185 ),
1186 json_output: bool = typer.Option(False, "--json", help="Print report as JSON."),
1187 detail_limit: int = typer.Option(
1188 200,
1189 "--detail-limit",
1190 min=0,
1191 help="Maximum per-source detail rows in JSON output; use 0 for no details.",
1192 ),
1193 full_details: bool = typer.Option(
1194 False,
1195 "--full-details",
1196 help="Include all per-source detail rows in JSON output.",
1197 ),
1198 stability_seconds: int = typer.Option(
1199 120,
1200 "--stability-seconds",
1201 min=0,
1202 help="Skip .jsonl files modified within this many seconds (default 120).",
1203 ),
1204 auto_memory: bool = typer.Option(
1205 False,
1206 "--auto-memory",
1207 help="Generate exact-deduped unverified memory nodes after refresh.",
1208 ),
1209 min_confidence: float = typer.Option(
1210 DEFAULT_CONFIDENCE,
1211 "--min-confidence",
1212 min=0.0,
1213 max=1.0,
1214 help="Minimum confidence for generated unverified memory nodes.",
1215 ),
1216 auto_memory_limit: int = typer.Option(
1217 DEFAULT_AUTO_MEMORY_LIMIT,
1218 "--auto-memory-limit",
1219 min=0,
1220 help="Maximum generated memory nodes per run; 0 means unlimited.",
1221 ),
1222 auto_memory_per_source_limit: int = typer.Option(
1223 DEFAULT_AUTO_MEMORY_PER_SOURCE_LIMIT,
1224 "--auto-memory-per-source-limit",
1225 min=0,
1226 help="Maximum generated memory nodes per source path per run; 0 means unlimited.",
1227 ),
1228) -> None:
1229 """Run automatic memory refresh (Phase 3 daily-use wrapper over global refresh).
1231 This command mirrors 'global refresh' for Phase 3.1. No generated memory
1232 nodes, watcher, persistent config, or MCP changes are active yet.
1233 """
1234 catalog_path = catalog if catalog is not None else default_catalog_path(home)
1235 db_path = db if db is not None else home / ".truenex-memory" / "truenex_memory.db"
1237 if skip_refresh and not auto_memory:
1238 _print_auto_run_error(
1239 "--skip-refresh requires --auto-memory",
1240 json_output=json_output,
1241 exit_code=1,
1242 )
1244 if skip_refresh and not db_path.exists():
1245 _print_auto_run_error(
1246 f"Database file not found: {db_path}",
1247 json_output=json_output,
1248 exit_code=1,
1249 )
1251 if not skip_refresh and not catalog_path.exists():
1252 if json_output:
1253 typer.echo(
1254 json.dumps(
1255 {"error": f"Catalog file not found: {catalog_path}"},
1256 indent=2,
1257 sort_keys=True,
1258 )
1259 )
1260 else:
1261 typer.echo(f"Error: Catalog file not found: {catalog_path}")
1262 raise typer.Exit(code=1)
1264 if skip_refresh:
1265 report = RefreshReport(refresh_skipped=True)
1266 else:
1267 report = run_global_refresh(
1268 catalog_path=catalog_path,
1269 db_path=db_path,
1270 dry_run=dry_run,
1271 stability_seconds=stability_seconds,
1272 )
1273 if auto_memory:
1274 generate_unverified_auto_memories(
1275 db_path,
1276 report,
1277 dry_run=dry_run,
1278 min_confidence=min_confidence,
1279 limit=auto_memory_limit,
1280 per_source_limit=auto_memory_per_source_limit,
1281 )
1283 if json_output:
1284 limit = None if full_details else detail_limit
1285 typer.echo(json.dumps(report.to_dict(detail_limit=limit), indent=2, sort_keys=True))
1286 else:
1287 typer.echo(format_refresh_report(report))
1288 if dry_run:
1289 typer.echo("\n(dry-run, DB/ledger unchanged)")
1292def _print_auto_run_error(message: str, *, json_output: bool, exit_code: int) -> None:
1293 if json_output:
1294 typer.echo(json.dumps({"error": message}, indent=2, sort_keys=True))
1295 else:
1296 typer.echo(f"Error: {message}")
1297 raise typer.Exit(code=exit_code)
1300@auto_app.command("status")
1301def auto_status(
1302 home: Path = typer.Option(
1303 Path.home(),
1304 "--home",
1305 help="User home directory for default paths.",
1306 ),
1307 catalog: Path | None = typer.Option(
1308 None,
1309 "--catalog",
1310 help="Path to the source catalog JSON file (default: <home>/.truenex-memory/sources.json).",
1311 ),
1312 db: Path | None = typer.Option(
1313 None,
1314 "--db",
1315 help="Path to the SQLite database (default: <home>/.truenex-memory/truenex_memory.db).",
1316 ),
1317 stability_seconds: int = typer.Option(
1318 120,
1319 "--stability-seconds",
1320 min=0,
1321 help="Treat recent unstable .jsonl sessions as transient within this many seconds.",
1322 ),
1323 json_output: bool = typer.Option(False, "--json", help="Print report as JSON."),
1324) -> None:
1325 """Show read-only automatic memory status (Phase 3.2)."""
1326 catalog_path = catalog if catalog is not None else default_catalog_path(home)
1327 db_path = db if db is not None else home / ".truenex-memory" / "truenex_memory.db"
1329 report = build_auto_status(
1330 catalog_path=catalog_path,
1331 db_path=db_path,
1332 stability_seconds=stability_seconds,
1333 )
1335 if json_output:
1336 typer.echo(json.dumps(report.to_dict(), indent=2, sort_keys=True))
1337 else:
1338 typer.echo(format_auto_status_report(report))
1341@auto_app.command("review")
1342def auto_review(
1343 home: Path = typer.Option(
1344 Path.home(),
1345 "--home",
1346 help="User home directory for default paths.",
1347 ),
1348 db: Path | None = typer.Option(
1349 None,
1350 "--db",
1351 help="Path to global memory database.",
1352 ),
1353 limit: int = typer.Option(
1354 DEFAULT_REVIEW_LIMIT,
1355 "--limit",
1356 min=1,
1357 help="Maximum generated memory nodes to display.",
1358 ),
1359 source: str | None = typer.Option(
1360 None,
1361 "--source",
1362 help="Case-insensitive substring filter for source_path.",
1363 ),
1364 content_chars: int = typer.Option(
1365 DEFAULT_CONTENT_CHARS,
1366 "--content-chars",
1367 min=40,
1368 help="Maximum characters shown for each text excerpt.",
1369 ),
1370 json_output: bool = typer.Option(False, "--json", help="Print JSON output."),
1371) -> None:
1372 """Review generated unverified auto memories without mutating the store."""
1373 db_path = db if db is not None else home / ".truenex-memory" / "truenex_memory.db"
1374 report = build_auto_memory_review(
1375 db_path=db_path,
1376 limit=limit,
1377 source_filter=source,
1378 content_chars=content_chars,
1379 )
1380 if json_output:
1381 typer.echo(json.dumps(report.to_dict(), indent=2, sort_keys=True))
1382 else:
1383 typer.echo(format_auto_memory_review(report))
1386@auto_app.command("approve")
1387def auto_approve(
1388 memory_id: str = typer.Argument(..., help="Generated unverified auto-memory id."),
1389 home: Path = typer.Option(
1390 Path.home(),
1391 "--home",
1392 help="User home directory for default paths.",
1393 ),
1394 db: Path | None = typer.Option(
1395 None,
1396 "--db",
1397 help="Path to global memory database.",
1398 ),
1399 json_output: bool = typer.Option(False, "--json", help="Print JSON output."),
1400) -> None:
1401 """Promote one generated unverified auto memory to active."""
1402 db_path = db if db is not None else home / ".truenex-memory" / "truenex_memory.db"
1403 report = approve_auto_memory(db_path, memory_id)
1404 if json_output:
1405 typer.echo(json.dumps(report.to_dict(), indent=2, sort_keys=True))
1406 else:
1407 typer.echo(format_auto_memory_lifecycle_report(report))
1410@auto_app.command("reject")
1411def auto_reject(
1412 memory_id: str = typer.Argument(..., help="Generated unverified auto-memory id."),
1413 home: Path = typer.Option(
1414 Path.home(),
1415 "--home",
1416 help="User home directory for default paths.",
1417 ),
1418 db: Path | None = typer.Option(
1419 None,
1420 "--db",
1421 help="Path to global memory database.",
1422 ),
1423 json_output: bool = typer.Option(False, "--json", help="Print JSON output."),
1424) -> None:
1425 """Mark one generated unverified auto memory obsolete without deleting it."""
1426 db_path = db if db is not None else home / ".truenex-memory" / "truenex_memory.db"
1427 report = reject_auto_memory(db_path, memory_id)
1428 if json_output:
1429 typer.echo(json.dumps(report.to_dict(), indent=2, sort_keys=True))
1430 else:
1431 typer.echo(format_auto_memory_lifecycle_report(report))
1434@auto_app.command("promote")
1435def auto_promote(
1436 memory_id: str = typer.Argument(..., help="Generated unverified auto-memory id."),
1437 title: str = typer.Option(
1438 ...,
1439 "--title",
1440 help="Curated title for the new active memory.",
1441 ),
1442 content: str = typer.Option(
1443 ...,
1444 "--content",
1445 help="Curated content for the new active memory.",
1446 ),
1447 memory_type: str = typer.Option(
1448 "note",
1449 "--type",
1450 help="Curated memory type: note, decision, issue, or pattern.",
1451 ),
1452 home: Path = typer.Option(
1453 Path.home(),
1454 "--home",
1455 help="User home directory for default paths.",
1456 ),
1457 db: Path | None = typer.Option(
1458 None,
1459 "--db",
1460 help="Path to global memory database.",
1461 ),
1462 dry_run: bool = typer.Option(
1463 False,
1464 "--dry-run",
1465 help="Validate and show the planned curated replacement without writing.",
1466 ),
1467 json_output: bool = typer.Option(False, "--json", help="Print JSON output."),
1468) -> None:
1469 """Create a curated active memory from one noisy unverified auto memory."""
1470 if memory_type not in CURATED_AUTO_MEMORY_TYPES:
1471 expected = ", ".join(sorted(CURATED_AUTO_MEMORY_TYPES))
1472 raise typer.BadParameter(
1473 f"invalid memory type {memory_type!r}; expected one of {expected}",
1474 param_hint="'--type'",
1475 )
1476 db_path = db if db is not None else home / ".truenex-memory" / "truenex_memory.db"
1477 try:
1478 report = promote_auto_memory(
1479 db_path,
1480 memory_id,
1481 title=title,
1482 content=content,
1483 memory_type=memory_type,
1484 dry_run=dry_run,
1485 )
1486 except ValueError as exc:
1487 raise typer.BadParameter(str(exc)) from exc
1488 if json_output:
1489 typer.echo(json.dumps(report.to_dict(), indent=2, sort_keys=True))
1490 else:
1491 typer.echo(format_auto_memory_lifecycle_report(report))
1494@auto_app.command("prune")
1495def auto_prune(
1496 home: Path = typer.Option(
1497 Path.home(),
1498 "--home",
1499 help="User home directory for default paths.",
1500 ),
1501 db: Path | None = typer.Option(
1502 None,
1503 "--db",
1504 help="Path to global memory database.",
1505 ),
1506 source: str | None = typer.Option(
1507 None,
1508 "--source",
1509 help="Case-insensitive substring filter for source_path.",
1510 ),
1511 limit: int = typer.Option(
1512 DEFAULT_PRUNE_LIMIT,
1513 "--limit",
1514 min=1,
1515 help="Maximum rejected auto memories to compact.",
1516 ),
1517 yes: bool = typer.Option(
1518 False,
1519 "--yes",
1520 help="Apply compaction. Without this flag the command is a dry-run.",
1521 ),
1522 json_output: bool = typer.Option(False, "--json", help="Print JSON output."),
1523) -> None:
1524 """Compact rejected auto memories into tombstones; dry-run by default."""
1525 db_path = db if db is not None else home / ".truenex-memory" / "truenex_memory.db"
1526 report = prune_auto_memories(
1527 db_path,
1528 source_filter=source,
1529 limit=limit,
1530 dry_run=not yes,
1531 )
1532 if json_output:
1533 typer.echo(json.dumps(report.to_dict(), indent=2, sort_keys=True))
1534 else:
1535 typer.echo(format_auto_memory_lifecycle_report(report))
1538def _print_ingest_report(report: dict[str, object], dry_run: bool) -> None:
1539 mode = "DRY-RUN" if dry_run else "INGEST"
1540 typer.echo(f"=== {mode} REPORT ===")
1542 index_now = report.get("index_now", [])
1543 if isinstance(index_now, list) and index_now:
1544 typer.echo(f"\nIndex now ({len(index_now)}):")
1545 for item in index_now:
1546 if isinstance(item, dict):
1547 sid = item.get("session_id", "")
1548 sid_str = f" session={sid}" if sid else ""
1549 typer.echo(
1550 f" [{item.get('source_type', '?')}] {item.get('source_path', '?')}"
1551 f" ({item.get('chars', 0)} chars){sid_str}"
1552 )
1554 parse_later = report.get("parse_later", [])
1555 if isinstance(parse_later, list) and parse_later:
1556 typer.echo(f"\nParse later ({len(parse_later)}):")
1557 for item in parse_later:
1558 if isinstance(item, dict):
1559 typer.echo(f" [{item.get('source_type', '?')}] {item.get('source_path', '?')}")
1561 skipped = report.get("skipped", [])
1562 if isinstance(skipped, list) and skipped:
1563 typer.echo(f"\nSkipped ({len(skipped)}):")
1564 for item in skipped:
1565 if isinstance(item, dict):
1566 typer.echo(
1567 f" [{item.get('source_type', '?')}] {item.get('source_path', '?')}"
1568 f" - {item.get('reason', '?')}"
1569 )
1571 errors = report.get("errors", [])
1572 if isinstance(errors, list) and errors:
1573 typer.echo(f"\nErrors ({len(errors)}):")
1574 for item in errors:
1575 if isinstance(item, dict):
1576 typer.echo(
1577 f" [{item.get('source_type', '?')}] {item.get('source_path', '?')}"
1578 f" - {item.get('error', '?')}"
1579 )
1581 total = (
1582 (len(index_now) if isinstance(index_now, list) else 0)
1583 + (len(parse_later) if isinstance(parse_later, list) else 0)
1584 + (len(skipped) if isinstance(skipped, list) else 0)
1585 + (len(errors) if isinstance(errors, list) else 0)
1586 )
1587 suffix = " (dry-run, DB unchanged)" if dry_run else ""
1588 typer.echo(f"\nTotal: {total} sources{suffix}")
1591def _validate_status(status: str) -> None:
1592 if status not in VALID_STATUSES:
1593 expected = ", ".join(sorted(VALID_STATUSES))
1594 raise typer.BadParameter(f"invalid status {status!r}; expected one of {expected}")
1597if __name__ == "__main__":
1598 app()