204 lines
6.4 KiB
Python
Executable File
204 lines
6.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""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) # noqa: E712
|
|
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()))
|