| |
| """odysseus-docs — shell wrapper for the living-document store. |
| |
| Documents are the AI-editable text/markdown/code files the assistant |
| creates in-place (think: a scratchpad it can revise). Each document |
| has a content blob + version history. |
| |
| odysseus-docs list [--active] [--limit N] |
| odysseus-docs show DOC_ID # current content + metadata |
| odysseus-docs versions DOC_ID # version history (no content) |
| odysseus-docs export DOC_ID --version N # full content of a specific version |
| odysseus-docs search "invoice" # title + current_content match |
| odysseus-docs delete DOC_ID # soft-delete (is_active=False) |
| |
| Reads `data/app.db` directly. |
| """ |
|
|
| from __future__ import annotations |
| import sys |
| import os, sys |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib")) |
| from cli import quiet_logs, emit, fail, common_parser, run, REPO_ROOT as _REPO_ROOT |
| quiet_logs() |
|
|
| import argparse, json, logging, os, sys |
| from pathlib import Path |
|
|
| try: |
| from core.database import SessionLocal, Document, DocumentVersion |
| quiet_logs() |
| except ModuleNotFoundError as e: |
| sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n") |
| sys.exit(2) |
|
|
|
|
| def _text_len(value) -> int: |
| return len(value) if isinstance(value, str) else 0 |
|
|
|
|
| def _serialize(d: "Document", include_content: bool = False) -> dict: |
| out = { |
| "id": d.id, |
| "title": d.title, |
| "language": d.language or "", |
| "session_id": d.session_id or "", |
| "version_count": d.version_count or 1, |
| "is_active": bool(d.is_active), |
| "tidy_verdict": d.tidy_verdict or "", |
| "content_length": _text_len(d.current_content), |
| "created_at": d.created_at.isoformat() if d.created_at else "", |
| "updated_at": d.updated_at.isoformat() if d.updated_at else "", |
| } |
| if include_content: |
| out["current_content"] = d.current_content or "" |
| return out |
|
|
|
|
| def cmd_list(args): |
| db = SessionLocal() |
| try: |
| q = db.query(Document) |
| if args.active: |
| q = q.filter(Document.is_active == True) |
| if args.session: |
| q = q.filter(Document.session_id == args.session) |
| q = q.order_by(Document.updated_at.desc()).limit(args.limit) |
| emit([_serialize(d) for d in q.all()], args) |
| finally: |
| db.close() |
|
|
|
|
| def cmd_show(args): |
| db = SessionLocal() |
| try: |
| d = db.get(Document, args.id) |
| if not d: |
| fail(f"no document with id {args.id!r}") |
| emit(_serialize(d, include_content=True), args) |
| finally: |
| db.close() |
|
|
|
|
| def cmd_versions(args): |
| db = SessionLocal() |
| try: |
| d = db.get(Document, args.id) |
| if not d: |
| fail(f"no document with id {args.id!r}") |
| rows = db.query(DocumentVersion).filter( |
| DocumentVersion.document_id == args.id |
| ).order_by(DocumentVersion.version_number.desc()).all() |
| emit([ |
| { |
| "version_number": v.version_number, |
| "summary": v.summary or "", |
| "source": v.source or "ai", |
| "content_length": _text_len(v.content), |
| } for v in rows |
| ], args) |
| finally: |
| db.close() |
|
|
|
|
| def cmd_export(args): |
| """Print the full content of a specific version as the JSON |
| payload's `content` field, or raw text via --raw.""" |
| db = SessionLocal() |
| try: |
| d = db.get(Document, args.id) |
| if not d: |
| fail(f"no document with id {args.id!r}") |
| if args.version is None: |
| content = d.current_content or "" |
| version = d.version_count or 1 |
| else: |
| v = db.query(DocumentVersion).filter( |
| DocumentVersion.document_id == args.id, |
| DocumentVersion.version_number == args.version, |
| ).first() |
| if not v: |
| fail(f"no version {args.version} on doc {args.id!r}") |
| content = v.content or "" |
| version = v.version_number |
| if args.raw: |
| sys.stdout.write(content) |
| if not content.endswith("\n"): |
| sys.stdout.write("\n") |
| return |
| emit({ |
| "id": d.id, |
| "title": d.title, |
| "version": version, |
| "content": content, |
| }, args) |
| finally: |
| db.close() |
|
|
|
|
| def cmd_search(args): |
| db = SessionLocal() |
| try: |
| like = f"%{args.query}%" |
| rows = db.query(Document).filter( |
| (Document.title.ilike(like)) | (Document.current_content.ilike(like)) |
| ).order_by(Document.updated_at.desc()).limit(args.limit).all() |
| emit([_serialize(d) for d in rows], args) |
| finally: |
| db.close() |
|
|
|
|
| def cmd_delete(args): |
| db = SessionLocal() |
| try: |
| d = db.get(Document, args.id) |
| if not d: |
| fail(f"no document with id {args.id!r}") |
| d.is_active = False |
| db.commit() |
| emit({"ok": True, "id": d.id, "soft_deleted": True}, args) |
| finally: |
| db.close() |
|
|
|
|
| def _build_parser(): |
| common = argparse.ArgumentParser(add_help=False) |
| common.add_argument("--pretty", action="store_true") |
| p = argparse.ArgumentParser(prog="odysseus-docs", parents=[common]) |
| sub = p.add_subparsers(dest="cmd", required=True) |
|
|
| pl = sub.add_parser("list", parents=[common]) |
| pl.add_argument("--active", action="store_true", help="only is_active=True") |
| pl.add_argument("--session", help="filter by session_id") |
| pl.add_argument("--limit", type=int, default=50) |
| pl.set_defaults(func=cmd_list) |
|
|
| psh = sub.add_parser("show", parents=[common]) |
| psh.add_argument("id") |
| psh.set_defaults(func=cmd_show) |
|
|
| pv = sub.add_parser("versions", parents=[common]) |
| pv.add_argument("id") |
| pv.set_defaults(func=cmd_versions) |
|
|
| pe = sub.add_parser("export", parents=[common]) |
| pe.add_argument("id") |
| pe.add_argument("--version", type=int, help="specific version (default: current)") |
| pe.add_argument("--raw", action="store_true", help="write raw content to stdout") |
| pe.set_defaults(func=cmd_export) |
|
|
| ps = sub.add_parser("search", parents=[common]) |
| ps.add_argument("query") |
| ps.add_argument("--limit", type=int, default=50) |
| ps.set_defaults(func=cmd_search) |
|
|
| pd = sub.add_parser("delete", parents=[common]) |
| pd.add_argument("id") |
| pd.set_defaults(func=cmd_delete) |
|
|
| return p |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(run(_build_parser())) |
|
|