Odysseus v1.0

This commit is contained in:
pewdiepie-archdaemon
2026-05-31 23:58:26 +09:00
commit e5c99a5eee
421 changed files with 271349 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Tab-completion for the `odysseus` umbrella + every `odysseus-*` CLI.
#
# Source from your shell rc:
# source /path/to/odysseus-ui/scripts/_completion/odysseus.bash
#
# Or wire it once per machine:
# sudo install -m 644 odysseus.bash /etc/bash_completion.d/odysseus
#
# What it does:
# - On the first word after `odysseus`, complete with the list of
# subcommands (`mail`, `calendar`, ...).
# - On subsequent words, complete with the subcommand's first-token
# subcommands (`list`, `show`, ...) which we cache by parsing the
# tool's own --help output. Updates lazily; refresh by running
# `_odysseus_refresh_cache`.
# - Same completion works for the individual `odysseus-foo` scripts.
_odysseus_scripts_dir() {
# Resolve the scripts/ dir from the script that sources us. We assume
# the user sourced the file directly out of scripts/_completion/.
local self="${BASH_SOURCE[0]}"
while [ -L "$self" ]; do self=$(readlink "$self"); done
cd "$(dirname "$self")/.." && pwd
}
declare -A _ODYSSEUS_SUBS_CACHE=()
_odysseus_refresh_cache() {
local dir="$(_odysseus_scripts_dir)"
_ODYSSEUS_SUBS_CACHE=()
# Prefer the project venv's Python so deps (bcrypt, sqlalchemy, ...)
# resolve. Falls back to system `python3` for container installs.
local py="$dir/../venv/bin/python"
[ -x "$py" ] || py="$(command -v python3)"
local f
for f in "$dir"/odysseus-*; do
[ -x "$f" ] || continue
case "$f" in *.bak|*.pyc|*.pre-*) continue ;; esac
local name="$(basename "$f")"
local sub="${name#odysseus-}"
local help_out
help_out=$("$py" "$f" --help 2>/dev/null) || continue
local commands
commands=$(echo "$help_out" | grep -oE '\{[a-z0-9_,-]+\}' | head -1 \
| tr -d '{}' | tr ',' ' ')
_ODYSSEUS_SUBS_CACHE[$sub]="$commands"
done
}
_odysseus_complete() {
[ ${#_ODYSSEUS_SUBS_CACHE[@]} -eq 0 ] && _odysseus_refresh_cache
local cur="${COMP_WORDS[COMP_CWORD]}"
local cmd="${COMP_WORDS[0]}"
# `odysseus <tab>` → list every subcommand
if [ "$cmd" = "odysseus" ]; then
if [ "$COMP_CWORD" -eq 1 ]; then
local subs="${!_ODYSSEUS_SUBS_CACHE[@]} help"
COMPREPLY=($(compgen -W "$subs" -- "$cur"))
return 0
fi
# `odysseus foo <tab>` — complete with foo's own subcommands
local sub="${COMP_WORDS[1]}"
# `odysseus help <tab>` lists every subcommand
if [ "$sub" = "help" ] && [ "$COMP_CWORD" -eq 2 ]; then
COMPREPLY=($(compgen -W "${!_ODYSSEUS_SUBS_CACHE[*]}" -- "$cur"))
return 0
fi
if [ "$COMP_CWORD" -eq 2 ]; then
COMPREPLY=($(compgen -W "${_ODYSSEUS_SUBS_CACHE[$sub]}" -- "$cur"))
return 0
fi
return 0
fi
# Direct `odysseus-foo <tab>` (no umbrella)
local sub="${cmd#odysseus-}"
if [ "$COMP_CWORD" -eq 1 ]; then
COMPREPLY=($(compgen -W "${_ODYSSEUS_SUBS_CACHE[$sub]}" -- "$cur"))
return 0
fi
}
# Register the completion for every odysseus-* script + the umbrella.
complete -F _odysseus_complete odysseus
for f in "$(_odysseus_scripts_dir)"/odysseus-*; do
[ -x "$f" ] || continue
case "$f" in *.bak|*.pyc|*.pre-*) continue ;; esac
complete -F _odysseus_complete "$(basename "$f")"
done

View File

@@ -0,0 +1,72 @@
#compdef odysseus odysseus-backup odysseus-calendar odysseus-contacts odysseus-cookbook odysseus-docs odysseus-gallery odysseus-mail odysseus-mcp odysseus-memory odysseus-notes odysseus-personal odysseus-preset odysseus-research odysseus-sessions odysseus-signature odysseus-skills odysseus-tasks odysseus-theme odysseus-webhook
# Zsh tab-completion for the odysseus umbrella + sub-CLIs.
#
# Drop in any directory on $fpath, e.g.:
# fpath=(/path/to/odysseus-ui/scripts/_completion $fpath)
# autoload -U compinit; compinit
#
# Then `odysseus <tab>` completes subcommands; `odysseus mail <tab>`
# completes mail subcommands; `odysseus-mail <tab>` works the same.
_odysseus_scripts_dir() {
local self="${(%):-%x}"
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
cd "${self:h}/.." && pwd
}
typeset -gA _odysseus_subs
_odysseus_refresh() {
_odysseus_subs=()
local dir="$(_odysseus_scripts_dir)"
local py="$dir/../venv/bin/python"
[[ -x "$py" ]] || py="$(command -v python3)"
local f sub help_out commands
for f in "$dir"/odysseus-*; do
[[ -x "$f" ]] || continue
case "$f" in
*.bak|*.pyc|*.pre-*) continue ;;
esac
sub="${${f:t}#odysseus-}"
help_out=$("$py" "$f" --help 2>/dev/null) || continue
commands=$(echo "$help_out" | grep -oE '\{[a-z0-9_,-]+\}' | head -1 \
| tr -d '{}' | tr ',' ' ')
_odysseus_subs[$sub]="$commands"
done
}
_odysseus() {
[[ ${#_odysseus_subs} -eq 0 ]] && _odysseus_refresh
local cmd="${words[1]}"
if [[ "$cmd" == "odysseus" ]]; then
if (( CURRENT == 2 )); then
local -a subs=(${(k)_odysseus_subs} help)
_describe 'subcommand' subs
return
fi
local sub="${words[2]}"
if [[ "$sub" == "help" ]] && (( CURRENT == 3 )); then
local -a subs=(${(k)_odysseus_subs})
_describe 'subcommand' subs
return
fi
if (( CURRENT == 3 )); then
local -a sc=(${(s/ /)_odysseus_subs[$sub]})
_describe 'command' sc
return
fi
return
fi
# odysseus-foo <tab>
local sub="${cmd#odysseus-}"
if (( CURRENT == 2 )); then
local -a sc=(${(s/ /)_odysseus_subs[$sub]})
_describe 'command' sc
return
fi
}
_odysseus "$@"

0
scripts/_lib/__init__.py Normal file
View File

122
scripts/_lib/cli.py Normal file
View File

@@ -0,0 +1,122 @@
"""scripts/_lib/cli.py — shared scaffolding for the `odysseus-*` CLIs.
Each top-level CLI imports a few helpers from here so they don't
have to redefine the same `_quiet_logs` / `_emit` / `_fail` /
parents-parser pattern. Usage:
from scripts._lib.cli import quiet_logs, emit, fail, common_parser, run
quiet_logs()
try:
from core.database import SessionLocal, Note # or whatever
quiet_logs()
except ModuleNotFoundError as e:
fail(f"{e}\\nhint: run from repo root with venv active.", code=2)
def cmd_list(args):
...
def build_parser():
p = common_parser("odysseus-foo", "Description.")
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", parents=[p._common_parents[0]])
pl.set_defaults(func=cmd_list)
return p
if __name__ == "__main__":
sys.exit(run(build_parser()))
The `--pretty` flag, repo-root-on-sys.path, and clean exit on
KeyboardInterrupt / unexpected exception are all handled centrally.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
from pathlib import Path
# Make repo root importable. Tools are invoked as `scripts/odysseus-foo`
# from any cwd; we want `from core.database import ...` to work.
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
def quiet_logs() -> None:
"""Force the root logger down to WARNING (overridable via
LOG_LEVEL=...). Call once before importing app modules and again
*after* — some submodules call `logging.basicConfig` during their
own import and re-raise the level to INFO."""
level_name = os.environ.get("LOG_LEVEL", "WARNING").upper()
level = getattr(logging, level_name, logging.WARNING)
root = logging.getLogger()
root.setLevel(level)
for handler in root.handlers:
handler.setLevel(level)
def emit(obj, args) -> None:
"""Write JSON to stdout. Pretty-print if `--pretty` was passed or
stdout is a TTY. Uses `default=str` so SQLAlchemy datetimes etc.
serialize cleanly."""
pretty = getattr(args, "pretty", False) or sys.stdout.isatty()
json.dump(
obj, sys.stdout,
indent=2 if pretty else None,
default=str,
ensure_ascii=False,
)
sys.stdout.write("\n")
def fail(msg: str, code: int = 1) -> "None":
"""Print an error to stderr and exit non-zero. Doesn't return."""
sys.stderr.write(f"error: {msg}\n")
sys.exit(code)
VERSION = "0.1.0" # bumped centrally; every odysseus-* CLI reports this
def common_parser(prog: str, description: str = "") -> argparse.ArgumentParser:
"""Return a top-level parser with `--pretty` and `--version` already
wired up, and a stashed `_common_parents` list each subcommand should
reuse via `parents=[...]` so the same flag works before AND after
the subcommand name."""
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--pretty", action="store_true",
help="Pretty-print JSON output")
p = argparse.ArgumentParser(prog=prog, description=description, parents=[common])
p.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
p._common_parents = [common] # consumed by callers when building sub-parsers
return p
def run(parser: argparse.ArgumentParser, argv=None) -> int:
"""Parse args, dispatch to `args.func(args)`, return an exit code.
Catches KeyboardInterrupt (→ 130) and uncaught exceptions (→ 1)
with a friendly stderr message.
Intercepts `--version` / `-V` before argparse can complain about the
missing required subcommand — `argparse.required=True` on the
subparsers fires first otherwise."""
raw_argv = sys.argv[1:] if argv is None else list(argv)
if any(a in ("--version", "-V") for a in raw_argv):
sys.stdout.write(f"{parser.prog} {VERSION}\n")
return 0
args = parser.parse_args(argv)
try:
args.func(args)
except KeyboardInterrupt:
sys.stderr.write("interrupted\n")
return 130
except SystemExit:
raise
except Exception as e:
fail(str(e))
return 0

234
scripts/add_hwfit_models.py Normal file
View File

@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""
add_hwfit_models.py — bulk-add Hugging Face models to the hwfit catalog
(services/hwfit/data/hf_models.json).
Adds:
* every model from one or more HF authors (e.g. cyankiwi's AWQ quants)
* any explicitly-listed repos
Metadata is taken from the HF Hub `list_models(full=True)` response plus the
repo name (which encodes the param size, e.g. "Qwen3.6-35B-A3B"). Param-less
names fall back to a single per-repo model_info() call to read safetensors.
Re-runnable: merges by `name`, leaving existing entries untouched unless
--overwrite is passed. Writes a .bak first.
Usage:
python3 scripts/add_hwfit_models.py
"""
import json
import os
import re
import sys
from datetime import datetime
from huggingface_hub import HfApi
DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "services", "hwfit", "data", "hf_models.json")
DATA_PATH = os.path.abspath(DATA_PATH)
AUTHORS = ["cyankiwi"]
# Specific repos to add (in addition to the authors above). Optional explicit
# overrides {repo: {field: value}} for things the name/metadata can't convey.
EXTRA_REPOS = {
"deepseek-ai/DeepSeek-V4-Flash": {"parameter_count": "168B", "quantization": "Q4_K_M"},
"MiniMaxAI/MiniMax-M2.7": {"parameter_count": "228.7B", "quantization": "Q4_K_M"},
"bullerwins/MiniMax-M2.7-REAP-172B-fp8": {"parameter_count": "172B", "quantization": "FP8"},
"cyankiwi/MiniMax-M2.7-AWQ-4bit": {"parameter_count": "228.7B", "quantization": "AWQ-4bit"},
}
# Tags that are not architecture names.
_GENERIC_TAGS = {
"transformers", "safetensors", "conversational", "text-generation",
"image-text-to-text", "text-generation-inference", "endpoints_compatible",
"autotrain_compatible", "compressed-tensors", "gguf", "mlx", "vllm", "4-bit",
"8-bit", "awq", "gptq", "fp8", "quantized", "chat",
}
api = HfApi()
def _parse_params(name):
"""Return (parameters_raw, active_parameters_or_None) from a repo name.
Handles dense ("27B") and MoE ("235B-A22B") naming."""
base = name.split("/")[-1]
active = None
m_active = re.search(r"-[Aa](\d+\.?\d*)[Bb](?![a-zA-Z])", base)
if m_active:
active = int(float(m_active.group(1)) * 1e9)
base_wo = base[:m_active.start()] + base[m_active.end():]
else:
base_wo = base
# First "<num>B" token that is a plausible size. Case-insensitive b, but the
# negative lookahead means "8bit"/"4bit" are NOT treated as "8B"/"4B".
total = None
for m in re.finditer(r"(\d+\.?\d*)[Bb](?![a-zA-Z])", base_wo):
total = int(float(m.group(1)) * 1e9)
break
return total, active
def _base_model_tag(tags):
"""Return the `base_model:...` repo id from tags, if any."""
for t in (tags or []):
if t.startswith("base_model:"):
return t.split(":")[-1]
return None
def _quant_from_name(name):
n = name.lower()
is8 = "8bit" in n or "8-bit" in n or "int8" in n
if "awq" in n:
return "AWQ-8bit" if is8 else "AWQ-4bit"
if "gptq" in n:
return "GPTQ-Int8" if is8 else "GPTQ-Int4"
if "mlx" in n:
if "6bit" in n:
return "mlx-6bit"
return "mlx-8bit" if is8 else "mlx-4bit"
if "fp8" in n:
return "FP8"
if "int4" in n or "4bit" in n or "4-bit" in n:
return "AWQ-4bit"
return "Q4_K_M"
def _arch_from_tags(tags):
for t in (tags or []):
if ":" in t or t in _GENERIC_TAGS:
continue
if re.fullmatch(r"[a-z0-9_]+", t) and any(c.isalpha() for c in t):
return t
return ""
def _entry_from_modelinfo(mi, overrides):
name = mi.id
provider = name.split("/")[0]
total, active = _parse_params(name)
# If the name has no size but an override supplies one, use that.
if total is None and overrides and overrides.get("parameter_count"):
total, _ov_active = _parse_params("x/" + overrides["parameter_count"])
# Next, try the base_model tag (the unquantized parent often names its size).
if total is None:
bm = _base_model_tag(getattr(mi, "tags", None))
if bm:
bt, ba = _parse_params(bm)
if bt:
total = bt
if ba and active is None:
active = ba
# Last resort: read safetensors param count (note: for quantized repos this
# is the *packed* count, so it's only an approximation).
if total is None:
try:
full = api.model_info(name, files_metadata=False)
st = getattr(full, "safetensors", None)
if st and getattr(st, "total", None):
total = int(st.total)
except Exception:
pass
if total is None:
return None # can't size it — skip
pb = total / 1e9
quant = _quant_from_name(name)
created = getattr(mi, "created_at", None)
rel = created.strftime("%Y-%m-%d") if created else datetime.utcnow().strftime("%Y-%m-%d")
# Rough RAM/VRAM hints (fit.py recomputes the real requirement from params+quant).
_BPP = {"AWQ-4bit": 0.58, "GPTQ-Int4": 0.58, "mlx-4bit": 0.55, "mlx-6bit": 0.85,
"AWQ-8bit": 1.1, "GPTQ-Int8": 1.1, "mlx-8bit": 1.1, "FP8": 1.1, "Q4_K_M": 0.6}
bpp = _BPP.get(quant, 0.6)
vram = round(pb * bpp + 0.5, 1)
entry = {
"name": name,
"provider": provider,
"parameter_count": f"{round(pb, 1)}B",
"parameters_raw": total,
"min_ram_gb": max(1.0, round(vram * 0.6, 1)),
"recommended_ram_gb": max(2.0, round(vram * 1.2, 1)),
"min_vram_gb": vram,
"quantization": quant,
"context_length": 32768,
"use_case": "General purpose",
"capabilities": [],
"pipeline_tag": getattr(mi, "pipeline_tag", None) or "text-generation",
"architecture": _arch_from_tags(getattr(mi, "tags", None)),
"hf_downloads": getattr(mi, "downloads", 0) or 0,
"hf_likes": getattr(mi, "likes", 0) or 0,
"release_date": rel,
"_discovered": True,
}
if active:
entry["is_moe"] = True
entry["active_parameters"] = active
entry.update(overrides or {})
# If an override set parameter_count, keep parameters_raw consistent.
if overrides and "parameter_count" in overrides and "parameters_raw" not in overrides:
t2, _ = _parse_params("x/" + overrides["parameter_count"])
if t2:
entry["parameters_raw"] = t2
return entry
def main():
with open(DATA_PATH) as f:
catalog = json.load(f)
by_name = {m["name"]: m for m in catalog}
existing = set(by_name)
overwrite = "--overwrite" in sys.argv
to_add = {}
# Authors
for author in AUTHORS:
print(f"Fetching author: {author} ...", flush=True)
models = list(api.list_models(author=author, full=True, cardData=True))
print(f" {len(models)} repos", flush=True)
for mi in models:
if mi.id in existing and not overwrite:
continue
ov = EXTRA_REPOS.get(mi.id)
entry = _entry_from_modelinfo(mi, ov)
if entry:
to_add[mi.id] = entry
# Explicit extra repos (not covered by an author scan)
for repo, ov in EXTRA_REPOS.items():
if repo in to_add:
continue
if repo in existing and not overwrite:
continue
try:
mi = api.model_info(repo, files_metadata=False)
except Exception as e:
print(f" SKIP {repo}: {e}", flush=True)
continue
entry = _entry_from_modelinfo(mi, ov)
if entry:
to_add[repo] = entry
if not to_add:
print("Nothing new to add.")
return
# Backup + merge
with open(DATA_PATH + ".bak", "w") as f:
json.dump(catalog, f, indent=2)
for name, entry in to_add.items():
by_name[name] = entry
merged = list(by_name.values())
with open(DATA_PATH, "w") as f:
json.dump(merged, f, indent=2)
print(f"\nAdded/updated {len(to_add)} models. Catalog now {len(merged)} (was {len(catalog)}).")
for n in sorted(to_add)[:20]:
e = to_add[n]
print(f" + {n} [{e['parameter_count']}, {e['quantization']}]")
if len(to_add) > 20:
print(f" ... and {len(to_add) - 20} more")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Claim all ownerless data for a specific user.
Run once after enabling multi-user auth to assign existing data to the admin.
Usage:
python scripts/claim_ownerless.py admin@example.com
"""
import sys
import os
import json
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def main():
if len(sys.argv) < 2:
print("Usage: python scripts/claim_ownerless.py <username>")
sys.exit(1)
owner = sys.argv[1]
print(f"Claiming all ownerless data for: {owner}\n")
# 1. Memories (JSON files)
for label, path in [
("memory.json", "data/memory.json"),
("skills.json", "data/skills.json"),
]:
if not os.path.exists(path):
print(f" {label}: not found, skipping")
continue
with open(path, "r") as f:
entries = json.load(f)
count = 0
for e in entries:
if not e.get("owner"):
e["owner"] = owner
count += 1
if count:
with open(path, "w") as f:
json.dump(entries, f, ensure_ascii=False, indent=2)
print(f" {label}: claimed {count} entries")
# 2. Database tables (sessions, gallery, comparisons, documents)
from core.database import SessionLocal, Session, Document
try:
from core.database import GalleryImage
except ImportError:
GalleryImage = None
try:
from core.database import Comparison
except ImportError:
Comparison = None
db = SessionLocal()
try:
# Sessions
count = db.query(Session).filter(Session.owner == None).update({"owner": owner})
print(f" sessions: claimed {count}")
# Documents
count = db.query(Document).filter(Document.session_id.in_(
db.query(Session.id).filter(Session.owner == owner)
)).update({"session_id": Document.session_id}, synchronize_session=False)
# Gallery
if GalleryImage:
count = db.query(GalleryImage).filter(GalleryImage.owner == None).update({"owner": owner})
print(f" gallery: claimed {count}")
# Comparisons
if Comparison:
count = db.query(Comparison).filter(Comparison.owner == None).update({"owner": owner})
print(f" comparisons: claimed {count}")
db.commit()
except Exception as e:
db.rollback()
print(f" ERROR: {e}")
finally:
db.close()
print(f"\nDone! All ownerless data now belongs to {owner}")
print("Restart the server: sudo systemctl restart odysseus-ui")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Create/remove the switchable, non-default 'Demo' EmailAccount in Odysseus.
Mirrors the existing local-Dovecot account (localhost:31143, STARTTLS) but points
at the throwaway demo@odysseus.local mailbox. Password is stored Fernet-encrypted
via the app's own secret_storage, exactly like real accounts.
python demo_account.py setup # add (or update) the 'Demo' account
python demo_account.py teardown # remove it
Run from the repo root so the app's modules import cleanly.
"""
from __future__ import annotations
import sys
import uuid
from pathlib import Path
# Make repo root importable regardless of CWD.
ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(ROOT))
from core.database import SessionLocal, EmailAccount, Base, engine # noqa: E402
from src.secret_storage import encrypt # noqa: E402
NAME = "Demo"
IMAP_USER = "demo@odysseus.local"
IMAP_PASSWORD = "demodemo"
# Owner empty-string => same list as the real Default account (switchable in the
# account dropdown).
OWNER = ""
def setup() -> int:
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
acct = db.query(EmailAccount).filter(
EmailAccount.name == NAME, EmailAccount.imap_user == IMAP_USER
).first()
if acct is None:
acct = EmailAccount(id=uuid.uuid4().hex, name=NAME)
db.add(acct)
acct.owner = OWNER
acct.is_default = False # never default — user switches to it
acct.enabled = True
acct.imap_host = "localhost"
acct.imap_port = 31143
acct.imap_user = IMAP_USER
acct.imap_password = encrypt(IMAP_PASSWORD)
acct.imap_starttls = True
# Local-only: no real SMTP. Point at a dead local port so an accidental
# "Send" during the demo fails locally instead of mailing anyone.
acct.smtp_host = "localhost"
acct.smtp_port = 2525
acct.smtp_user = IMAP_USER
acct.smtp_password = encrypt(IMAP_PASSWORD)
acct.from_address = IMAP_USER
db.commit()
print(f"'{NAME}' account ready (id={acct.id}, non-default, switchable).")
return 0
finally:
db.close()
def teardown() -> int:
db = SessionLocal()
try:
rows = db.query(EmailAccount).filter(
EmailAccount.name == NAME, EmailAccount.imap_user == IMAP_USER
).all()
for r in rows:
db.delete(r)
db.commit()
print(f"removed {len(rows)} '{NAME}' account row(s).")
return 0
finally:
db.close()
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "setup":
raise SystemExit(setup())
if cmd == "teardown":
raise SystemExit(teardown())
print(__doc__)
raise SystemExit(2)

71
scripts/demo_email/manage.sh Executable file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Manage the isolated email demo: a throwaway, local-only Dovecot user +
# a switchable 'Demo' account in Odysseus + fake seed mail.
#
# ./manage.sh setup # add Dovecot user, reload, create account, seed mail
# ./manage.sh reseed # wipe + re-seed the fake mail (clean slate)
# ./manage.sh teardown # remove account row, Dovecot user, and the maildir
#
# Safe by design: the demo user is in NO mbsync channel, so nothing here ever
# reaches a real mail server. Non-demo accounts are untouched.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DOCKER_DIR="${ODYSSEUS_DEMO_MAIL_DIR:-$HOME/docker/snappymail}"
USERS_FILE="$DOCKER_DIR/dovecot/conf/users"
DEMO_USER="demo@odysseus.local"
DEMO_PASS="demodemo"
HERE="$REPO/scripts/demo_email"
# Use the app's venv (has bcrypt/httpx + the app modules); fall back to python3.
PY="$REPO/venv/bin/python"; [ -x "$PY" ] || PY="python3"
reload_dovecot() {
docker exec dovecot doveadm reload 2>/dev/null || docker restart dovecot >/dev/null
sleep 1
}
add_user() {
if grep -q "^${DEMO_USER}:" "$USERS_FILE"; then
echo "Dovecot user $DEMO_USER already present."
else
printf '%s:{PLAIN}%s\n' "$DEMO_USER" "$DEMO_PASS" >> "$USERS_FILE"
echo "Added Dovecot user $DEMO_USER."
fi
reload_dovecot
}
remove_user() {
if grep -q "^${DEMO_USER}:" "$USERS_FILE"; then
# portable in-place delete of the demo line
grep -v "^${DEMO_USER}:" "$USERS_FILE" > "$USERS_FILE.tmp" && mv "$USERS_FILE.tmp" "$USERS_FILE"
echo "Removed Dovecot user $DEMO_USER."
reload_dovecot
fi
# Drop the maildir too (best-effort; the volume path needs root).
docker exec dovecot sh -lc "rm -rf '/srv/vmail/${DEMO_USER}'" 2>/dev/null \
&& echo "Removed maildir for $DEMO_USER." || true
}
case "${1:-}" in
setup)
add_user
"$PY" "$HERE/demo_account.py" setup
"$PY" "$HERE/seed_demo_emails.py" --reset
echo
echo "Done. In Odysseus, switch to the 'Demo' account to show off the inbox."
;;
reseed)
"$PY" "$HERE/seed_demo_emails.py" --reset
;;
teardown)
# Clear seeded mail + cached AI reply/summary while the user still exists.
"$PY" "$HERE/seed_demo_emails.py" --wipe-only || true
"$PY" "$HERE/demo_account.py" teardown || true
remove_user
echo "Demo torn down. Real accounts untouched."
;;
*)
sed -n '2,12p' "$0"
exit 2
;;
esac

View File

@@ -0,0 +1,394 @@
#!/usr/bin/env python3
"""Seed a throwaway, local-only mailbox with fake demo emails.
This populates the `demo@odysseus.local` Dovecot account (which has NO mbsync
channel, so nothing here ever touches a real server) with a curated, obviously
fake but realistic set of messages — varied senders, read/unread/flagged mix, a
reply thread, an attachment, a newsletter, a calendar invite, an urgent one, and
a spammy one — so the email assistant's summarize / reply / tag / spam / calendar
features can be shown off without exposing any real mail.
Idempotent: `--reset` wipes every mailbox in the demo account first, so re-running
gives a clean, identical inbox every time.
Usage:
python seed_demo_emails.py # append demo mail (creates folders)
python seed_demo_emails.py --reset # wipe the demo account, then re-seed
python seed_demo_emails.py --reset --wipe-only # just empty it
Connection mirrors the app's local-Dovecot account (localhost:31143, STARTTLS).
Override via env: DEMO_IMAP_HOST/PORT/USER/PASSWORD.
"""
from __future__ import annotations
import argparse
import imaplib
import sqlite3
import sys
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
from pathlib import Path
import os
HOST = os.getenv("DEMO_IMAP_HOST", "localhost")
PORT = int(os.getenv("DEMO_IMAP_PORT", "31143"))
USER = os.getenv("DEMO_IMAP_USER", "demo@odysseus.local")
PASSWORD = os.getenv("DEMO_IMAP_PASSWORD", "demodemo")
# Marker header on every message we create — lets a human (or a future cleanup)
# tell demo mail apart at a glance.
MARKER = ("X-Odysseus-Demo", "1")
DEMO_OWNER_ADDR = USER # the demo "you"
# The "could've just been a search" email gets a FIXED Message-ID so we can
# pre-seed a matching cached AI reply (keyed by Message-ID) in the app's email
# cache DB — the read path attaches it as cached_ai_reply. This makes the
# "my agent already looked it up and drafted the answer" beat reliable on stage.
LOOKUP_MSGID = "<demo-lookup-deepseek@odysseus.local>"
# The app's email cache lives at <repo>/data/scheduled_emails.db (email_summaries,
# email_ai_replies, ... keyed by Message-ID).
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
CACHE_DB = _REPO_ROOT / "data" / "scheduled_emails.db"
def _connect() -> imaplib.IMAP4:
"""Connect + STARTTLS like the app does."""
conn = imaplib.IMAP4(HOST, PORT)
conn.starttls()
conn.login(USER, PASSWORD)
return conn
def _tiny_pdf(title: str) -> bytes:
"""A minimal but valid one-page PDF, so the attachment is real and openable."""
body = (
f"BT /F1 18 Tf 72 700 Td ({title}) Tj ET\n"
"BT /F1 12 Tf 72 670 Td (This is a fake demo invoice. Not a real charge.) Tj ET"
).encode("latin-1", "replace")
objs = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
b"/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
b"<< /Length %d >>\nstream\n%s\nendstream" % (len(body), body),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
]
out = bytearray(b"%PDF-1.4\n")
offsets = []
for i, o in enumerate(objs, start=1):
offsets.append(len(out))
out += b"%d 0 obj\n" % i + o + b"\nendobj\n"
xref_pos = len(out)
out += b"xref\n0 %d\n" % (len(objs) + 1)
out += b"0000000000 65535 f \n"
for off in offsets:
out += b"%010d 00000 n \n" % off
out += (b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF"
% (len(objs) + 1, xref_pos))
return bytes(out)
def _ics(summary: str, start: datetime, mins: int) -> str:
end = start + timedelta(minutes=mins)
fmt = "%Y%m%dT%H%M%SZ"
return (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Odysseus Demo//EN\r\n"
"METHOD:REQUEST\r\nBEGIN:VEVENT\r\n"
f"UID:{make_msgid()}\r\n"
f"DTSTAMP:{datetime.now(timezone.utc).strftime(fmt)}\r\n"
f"DTSTART:{start.strftime(fmt)}\r\nDTEND:{end.strftime(fmt)}\r\n"
f"SUMMARY:{summary}\r\nLOCATION:Video call\r\n"
"ORGANIZER;CN=Demo Team:mailto:calendar@dfx522.example\r\n"
f"ATTENDEE;CN=You:mailto:{DEMO_OWNER_ADDR}\r\n"
"END:VEVENT\r\nEND:VCALENDAR\r\n"
)
def _msg(*, frm, to=None, subject, text, html=None, days_ago=0, hours_ago=0,
in_reply_to=None, references=None, msg_id=None,
pdf=None, pdf_name="invoice.pdf", ics=None) -> tuple[EmailMessage, datetime]:
m = EmailMessage()
m["From"] = frm
m["To"] = to or f"You <{DEMO_OWNER_ADDR}>"
m["Subject"] = subject
when = datetime.now(timezone.utc) - timedelta(days=days_ago, hours=hours_ago)
m["Date"] = formatdate(when.timestamp(), localtime=False)
m["Message-ID"] = msg_id or make_msgid(domain="odysseus.local")
if in_reply_to:
m["In-Reply-To"] = in_reply_to
m["References"] = references or in_reply_to
m[MARKER[0]] = MARKER[1]
m.set_content(text)
if html:
m.add_alternative(html, subtype="html")
if pdf is not None:
m.add_attachment(pdf, maintype="application", subtype="pdf", filename=pdf_name)
if ics is not None:
m.add_attachment(ics.encode("utf-8"), maintype="text", subtype="calendar",
filename="invite.ics", params={"method": "REQUEST"})
return m, when
def build_dataset() -> list[dict]:
"""Returns a list of {mailbox, flags, msg, when} dicts. Themed/playful, fake."""
items: list[dict] = []
def add(mailbox, flags, msg_when):
msg, when = msg_when
items.append({"mailbox": mailbox, "flags": flags, "msg": msg, "when": when})
# 1. Recruiter — unread. (Subject has an emoji to also show the mono-emoji render.)
add("INBOX", "", _msg(
frm="Brogan O'Hara <talent@northstar-labs.example>",
subject="We want you on the Northstar AI team 🚀",
days_ago=0, hours_ago=2,
text=("Hey,\n\nSaw your work on the Odysseus stack — seriously impressive. "
"We're building an agentic AI platform and your name keeps coming up.\n\n"
"Any chance you're open to a quick chat this week? Comp is competitive and "
"the team is fully remote.\n\nCheers,\nBrogan\nHead of Talent, Northstar Labs"),
html=("<p>Hey,</p><p>Saw your work on the <b>Odysseus</b> stack — seriously "
"impressive. We're building an agentic AI platform and your name keeps coming "
"up.</p><p>Any chance you're open to a quick chat this week? Comp is competitive "
"and the team is fully remote.</p><p>Cheers,<br>Brogan<br><i>Head of Talent, "
"Northstar Labs</i></p>")))
# 1b. The "could've just been a search" email — unread, newest (top of inbox).
# Fixed Message-ID so we can pre-seed the agent's researched reply.
add("INBOX", "", _msg(
frm="Greg <greg@odysseus-demo.example>",
subject="quick q for the slide — DeepSeek-V3 param count?",
msg_id=LOOKUP_MSGID, days_ago=0, hours_ago=0,
text=("hey! sorry to bug you — in a meeting and someone asked and i'm "
"blanking: how many parameters does DeepSeek-V3 actually have, total "
"vs active? need it for the comparison slide. could you look it up real "
"quick? 🙏\n\nty!\nGreg"),
html=("<p>hey! sorry to bug you — in a meeting and someone asked and i'm "
"blanking: <b>how many parameters does DeepSeek-V3 actually have, total "
"vs active?</b> need it for the comparison slide. could you look it up "
"real quick? 🙏</p><p>ty!<br>Greg</p>")))
# 2. Newsletter — unread.
add("INBOX", "", _msg(
frm="Local Models Weekly <news@localmodels.example>",
subject="This week in local AI: tiny models, big benchmarks",
days_ago=1,
text=("LOCAL MODELS WEEKLY — Issue #142\n\n"
"• Local LLMs that fit in a shoebox GPU\n"
"• Why your RAG pipeline needs evaluation\n"
"• Cave of the week: someone ran 8x4090D in a closet\n\n"
"Unsubscribe any time.")))
# 3. Reply thread — original is in Sent, the reply lands unread in INBOX.
orig_id = make_msgid(domain="odysseus.local")
add("Sent", "(\\Seen)", _msg(
frm=f"You <{DEMO_OWNER_ADDR}>",
to="Alex <alex@creator.example>",
subject="stream setup for Saturday",
msg_id=orig_id, days_ago=2,
text=("Yo — for Saturday's stream, are we doing the dual-PC setup or just the "
"one rig? Need to know before I cable everything.\n\n- You")))
add("INBOX", "", _msg(
frm="Alex <alex@creator.example>",
subject="Re: stream setup for Saturday",
in_reply_to=orig_id, references=orig_id,
days_ago=0, hours_ago=5,
text=("Dual-PC, definitely. Last time the single rig choked when we ran the "
"AI overlay + OBS + the game. Bring the capture card too.\n\n"
"Thanks,\nAlex")))
# 4. Invoice with a real PDF attachment.
add("INBOX", "(\\Seen)", _msg(
frm="CloudCompute Billing <billing@cloudcompute.example>",
subject="Your invoice #DFX-2042 is ready",
days_ago=3,
text=("Hi,\n\nYour CloudCompute invoice #DFX-2042 for $42.00 is attached "
"(GPU minutes, May).\n\nNo action needed — auto-charged to your card on "
"the 1st.\n\n— CloudCompute"),
pdf=_tiny_pdf("Invoice #DFX-2042 - $42.00"), pdf_name="invoice_DFX-2042.pdf"))
# 5. Calendar invite (ICS attachment + explicit time in body).
nextmon = datetime.now(timezone.utc) + timedelta(days=(7 - datetime.now().weekday()) % 7 or 7)
nextmon = nextmon.replace(hour=10, minute=0, second=0, microsecond=0)
add("INBOX", "", _msg(
frm="Demo Team <calendar@dfx522.example>",
subject="Invitation: Demo Team sync — Monday 10:00",
days_ago=0, hours_ago=20,
text=("You're invited to the weekly Demo Team sync.\n\n"
f"When: Monday {nextmon:%b %d} at 10:00 UTC (30 min)\n"
"Where: Video call\n\n"
"Agenda: stall-detector rollout, emoji icons, demo prep."),
ics=_ics("Demo Team sync", nextmon, 30)))
# 6. Urgent — flagged + unread.
add("INBOX", "(\\Flagged)", _msg(
frm="Ops Bot <ops@odysseus-demo.example>",
subject="[URGENT] prod is on fire 🔥 — odysseus-ui 502s",
days_ago=0, hours_ago=1,
text=("PAGE: odysseus-ui is returning 502s on the /api/chat endpoint.\n"
"Error rate 38% over the last 5 min. Last deploy was 12 min ago.\n\n"
"Need eyes ASAP. Reply here or join the incident call.")))
# 7. Spammy — obvious, for the spam verdict.
add("INBOX", "", _msg(
frm="Prize Department <winner@totally-legit-prizes.example>",
subject="CONGRATULATIONS!!! You have WON 1,000,000 GOLD COINS!!!",
days_ago=4,
text=("Dear Lucky Winner,\n\nYou have been SELECTED to receive ONE MILLION "
"gold coins!!! To claim, simply reply with your bank details and "
"a small processing fee of 50 coins.\n\nACT NOW — offer expires in 3 hours!!!\n\n"
"Totally Legit Prizes Inc.")))
# 8. A normal, already-read personal one.
add("INBOX", "(\\Seen)", _msg(
frm="Mom <mom@family.example>",
subject="did you eat??",
days_ago=1, hours_ago=3,
text=("hi sweetie just checking did you eat today. you work too much on the "
"computer. call me. love mom xoxo")))
return items
def _seed_cache() -> None:
"""Pre-seed the app's email cache so the lookup email arrives with a summary
and an AI reply that has clearly 'done the search' (answer + source)."""
if not CACHE_DB.parent.exists():
print(f" (skip cache seed: {CACHE_DB.parent} missing)")
return
reply = (
"Hi Greg,\n\n"
"Looked it up — DeepSeek-V3 is a 671B-parameter Mixture-of-Experts model, "
"with 37B parameters active per token (256 routed experts + 1 shared). It "
"was trained on ~14.8T tokens and ships with a 128K-token context window.\n\n"
"Source: DeepSeek-V3 Technical Report (arXiv:2412.19437) and the official "
"model card on Hugging Face.\n\n"
"Hope that unblocks the slide!\n\n"
"— drafted for you by your Odysseus assistant"
)
summary = ("Greg needs the DeepSeek-V3 parameter count (total vs active) for a "
"comparison slide. Quick factual lookup — answerable with a search.")
now = datetime.now(timezone.utc).isoformat()
con = sqlite3.connect(str(CACHE_DB))
try:
con.execute("""CREATE TABLE IF NOT EXISTS email_ai_replies (
message_id TEXT PRIMARY KEY, uid TEXT, folder TEXT, reply TEXT NOT NULL,
model_used TEXT, created_at TEXT NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS email_summaries (
message_id TEXT PRIMARY KEY, uid TEXT, folder TEXT, subject TEXT,
sender TEXT, summary TEXT NOT NULL, model_used TEXT, created_at TEXT NOT NULL)""")
con.execute(
"INSERT OR REPLACE INTO email_ai_replies "
"(message_id, uid, folder, reply, model_used, created_at) VALUES (?,?,?,?,?,?)",
(LOOKUP_MSGID, "", "INBOX", reply, "demo", now))
con.execute(
"INSERT OR REPLACE INTO email_summaries "
"(message_id, uid, folder, subject, sender, summary, model_used, created_at) "
"VALUES (?,?,?,?,?,?,?,?)",
(LOOKUP_MSGID, "", "INBOX", "quick q for the slide — DeepSeek-V3 param count?",
"greg@odysseus-demo.example", summary, "demo", now))
con.commit()
print(" pre-seeded cached AI reply + summary for the lookup email.")
finally:
con.close()
def _clear_cache() -> None:
"""Remove the pre-seeded cache rows for the lookup email (best-effort)."""
if not CACHE_DB.exists():
return
con = sqlite3.connect(str(CACHE_DB))
try:
for tbl in ("email_ai_replies", "email_summaries"):
try:
con.execute(f"DELETE FROM {tbl} WHERE message_id = ?", (LOOKUP_MSGID,))
except sqlite3.OperationalError:
pass
con.commit()
finally:
con.close()
def _ensure_mailbox(conn: imaplib.IMAP4, name: str) -> None:
if name.upper() == "INBOX":
return
typ, _ = conn.select(name)
if typ != "OK":
conn.create(name)
def _wipe(conn: imaplib.IMAP4) -> int:
"""Delete every message in every mailbox of this (throwaway) account.
Guard: the connection params are env-overridable, so refuse to run the
destructive expunge unless the target is unmistakably the local demo
account — otherwise a misconfigured DEMO_IMAP_USER/HOST could irreversibly
wipe a real mailbox. Override only with DEMO_ALLOW_WIPE=1 (you must mean it).
"""
safe_target = USER.endswith("@odysseus.local") or HOST in ("localhost", "127.0.0.1", "::1")
if not safe_target and os.getenv("DEMO_ALLOW_WIPE") != "1":
raise SystemExit(
f"refusing to wipe non-demo target {USER}@{HOST}:{PORT}"
f"set DEMO_ALLOW_WIPE=1 to override")
typ, boxes = conn.list()
n = 0
names = []
if typ == "OK":
for raw in boxes:
line = raw.decode(errors="replace")
# last token, possibly quoted, is the mailbox name
name = line.split(' "/" ')[-1].split(' "." ')[-1].strip().strip('"')
names.append(name)
for name in set(names) | {"INBOX"}:
if conn.select(name)[0] != "OK":
continue
typ, data = conn.search(None, "ALL")
if typ == "OK" and data and data[0]:
ids = data[0].split()
for i in ids:
conn.store(i, "+FLAGS", "\\Deleted")
n += len(ids)
conn.expunge()
return n
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--reset", action="store_true", help="wipe the account before seeding")
ap.add_argument("--wipe-only", action="store_true", help="only wipe, don't seed")
args = ap.parse_args()
try:
conn = _connect()
except Exception as e:
print(f"ERROR: could not connect to {USER}@{HOST}:{PORT}{e}", file=sys.stderr)
print("Is the Dovecot user created + Dovecot reloaded?", file=sys.stderr)
return 1
try:
if args.reset or args.wipe_only:
removed = _wipe(conn)
_clear_cache()
print(f"wiped {removed} message(s) from {USER}")
if args.wipe_only:
return 0
items = build_dataset()
for it in items:
_ensure_mailbox(conn, it["mailbox"])
dt = imaplib.Time2Internaldate(it["when"].timestamp())
conn.append(it["mailbox"], it["flags"], dt, it["msg"].as_bytes())
_seed_cache()
print(f"seeded {len(items)} demo message(s) into {USER} "
f"(INBOX + Sent). Switch to the 'Demo' account in Odysseus to view.")
return 0
finally:
try:
conn.logout()
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())

1095
scripts/diffusion_server.py Normal file

File diff suppressed because it is too large Load Diff

39
scripts/encode_previews.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Encode a source screen-recording (.mkv) into web-optimized preview clips for
# the landing page: docs/<name>.webm (VP9) + docs/<name>.mp4 (H.264).
#
# ./encode_previews.sh <input> <name> [max_secs]
#
# - scales to 720p height (even dims), strips audio, yuv420p for broad support
# - if the clip is longer than max_secs (default 30), it's sped up to fit, so
# loops stay snappy instead of dragging
# - MP4 gets +faststart so it starts without downloading the whole file
set -euo pipefail
IN="${1:?input file}"
NAME="${2:?output basename}"
MAX="${3:-30}"
OUT_DIR="$(cd "$(dirname "$0")/../docs" && pwd)"
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$IN" | cut -d. -f1)
dur=${dur:-0}
SPEED_VF=""
if [ "$dur" -gt "$MAX" ] && [ "$MAX" -gt 0 ]; then
# setpts factor <1 speeds up; e.g. 30/60 = 0.5x duration => 2x speed
factor=$(awk "BEGIN{printf \"%.4f\", $MAX/$dur}")
SPEED_VF="setpts=${factor}*PTS,"
echo " ($IN is ${dur}s > ${MAX}s -> speeding up x$(awk "BEGIN{printf \"%.2f\", $dur/$MAX}"))"
fi
VF="${SPEED_VF}scale=-2:720:flags=lanczos"
echo " -> $NAME.webm"
ffmpeg -y -loglevel error -i "$IN" -an -vf "$VF" \
-c:v libvpx-vp9 -b:v 0 -crf 34 -row-mt 1 -deadline good -cpu-used 2 \
-pix_fmt yuv420p "$OUT_DIR/$NAME.webm"
echo " -> $NAME.mp4"
ffmpeg -y -loglevel error -i "$IN" -an -vf "$VF" \
-c:v libx264 -crf 25 -preset slow -pix_fmt yuv420p \
-movflags +faststart "$OUT_DIR/$NAME.mp4"
ls -lh "$OUT_DIR/$NAME.webm" "$OUT_DIR/$NAME.mp4" | awk '{print " "$5"\t"$9}'

9
scripts/fix_paths.py Normal file
View File

@@ -0,0 +1,9 @@
import fileinput
import sys
# Read app.py and replace the BASE_DIR line
for line in fileinput.input('app.py', inplace=True):
if line.startswith('BASE_DIR = '):
print('BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + "/"')
else:
print(line, end='')

182
scripts/hf_download.py Normal file
View File

@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Download HuggingFace models with clean pipe-friendly progress output.
Usage:
python3 scripts/hf_download.py <repo_id> [--include "pattern"]
Prints lines like:
FILE model.safetensors [########------------] 42% 1.23/2.91GB 156.3MB/s
DONE /path/to/cached/model
"""
import argparse
import sys
import time
import os
_last_print = {}
class PipeTqdm:
"""Minimal tqdm replacement that prints simple progress lines to stdout."""
def __init__(self, *args, **kwargs):
self.iterable = args[0] if args else kwargs.get("iterable")
self.total = kwargs.get("total", None)
self.desc = kwargs.get("desc", "")
self.unit = kwargs.get("unit", "it")
self.n = 0
self.start_t = time.time()
self.disable = False
self._closed = False
if self.iterable is not None and self.total is None:
try:
self.total = len(self.iterable)
except (TypeError, AttributeError):
pass
def __iter__(self):
if self.iterable is None:
return
for item in self.iterable:
yield item
self.update(1)
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
def __len__(self):
return self.total or 0
def update(self, n=1):
self.n += n
total = self.total or 0
if total == 0:
return
now = time.time()
key = id(self)
# Throttle to every 0.5s, always print on completion
if now - _last_print.get(key, 0) < 0.5 and self.n < total:
return
_last_print[key] = now
pct = int(100 * self.n / total)
elapsed = now - self.start_t
speed = self.n / elapsed if elapsed > 0 else 0
desc = (self.desc or "").strip()
# Format sizes
if total >= 1024 ** 3:
done_s = f"{self.n / (1024**3):.2f}"
total_s = f"{total / (1024**3):.2f}GB"
speed_s = f"{speed / (1024**2):.1f}MB/s"
elif total >= 1024 ** 2:
done_s = f"{self.n / (1024**2):.1f}"
total_s = f"{total / (1024**2):.1f}MB"
speed_s = f"{speed / (1024**2):.1f}MB/s"
else:
done_s = str(self.n)
total_s = str(total)
speed_s = f"{speed:.0f}/s"
# ASCII progress bar
bar_len = 20
filled = int(bar_len * self.n / total)
bar = "#" * filled + "-" * (bar_len - filled)
print(f"FILE {desc} [{bar}] {pct}% {done_s}/{total_s} {speed_s}", flush=True)
def set_description(self, desc=None, refresh=True):
self.desc = desc or ""
def set_postfix(self, *args, **kwargs):
pass
def set_postfix_str(self, s="", refresh=True):
pass
def reset(self, total=None):
self.n = 0
if total is not None:
self.total = total
self.start_t = time.time()
def refresh(self):
pass
def close(self):
self._closed = True
def clear(self):
pass
def display(self, msg=None, pos=None):
pass
@property
def format_dict(self):
return {"n": self.n, "total": self.total, "elapsed": time.time() - self.start_t}
def _patch_tqdm():
"""Replace tqdm everywhere with our pipe-friendly version."""
import tqdm as tqdm_mod
# Replace the main class
tqdm_mod.tqdm = PipeTqdm
tqdm_mod.auto.tqdm = PipeTqdm
# huggingface_hub uses tqdm.auto or its own utils.tqdm
try:
import huggingface_hub.utils
huggingface_hub.utils.tqdm = PipeTqdm
# Also patch the _tqdm module if it exists
if hasattr(huggingface_hub.utils, "_tqdm"):
huggingface_hub.utils._tqdm.tqdm = PipeTqdm
except (ImportError, AttributeError):
pass
def main():
parser = argparse.ArgumentParser()
parser.add_argument("repo_id", help="HuggingFace repo (e.g. meta-llama/Llama-3-8B)")
parser.add_argument("--include", help="File pattern to include (e.g. '*Q4_K_M*')")
args = parser.parse_args()
# Disable HF progress bars (we provide our own)
os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "0"
# Enable Rust-backed parallel downloader if available — big throughput win.
# Must be set before importing huggingface_hub.
try:
import hf_transfer # noqa: F401
os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
except ImportError:
print("HINT pip install hf_transfer for faster downloads", flush=True)
_patch_tqdm()
from huggingface_hub import snapshot_download
kwargs = {
"repo_id": args.repo_id,
"max_workers": int(os.environ.get("HF_HUB_DOWNLOAD_MAX_WORKERS", "8")),
}
if args.include:
kwargs["allow_patterns"] = [args.include]
print(f"START {args.repo_id}", flush=True)
try:
path = snapshot_download(**kwargs)
print(f"DONE {path}", flush=True)
except Exception as e:
print(f"ERROR {e}", file=sys.stderr, flush=True)
sys.exit(1)
if __name__ == "__main__":
main()

114
scripts/index_documents.py Normal file
View File

@@ -0,0 +1,114 @@
"""
index_documents.py
A standalone script to index documents from the personal_docs directory
into the vector database using RAGManager. This script scans for text files,
processes them with proper chunking, and adds them to the vector database
with progress reporting and final statistics.
Features:
1. Imports RAGManager from rag_manager
2. Scans personal_docs directory for .txt, .md, .json files
3. Reads each file, chunks it (1000 chars with 200 overlap), and adds to vector database
4. Shows progress during processing and final statistics
"""
import os
import logging
import sys
from pathlib import Path
from typing import List, Tuple
# Configure logging for the script
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
def main():
"""Main function to index documents from personal_docs directory."""
# Import RAGManager
try:
from src.rag_manager import RAGManager
logger.info("Successfully imported RAGManager")
except ImportError as e:
logger.error(f"Failed to import RAGManager: {e}")
logger.error("Make sure rag_manager.py is in the same directory and accessible")
return
# Initialize RAGManager
rag_manager = RAGManager()
# Directory to scan
docs_directory = "data/personal_docs"
directory_path = Path(docs_directory)
# Check if directory exists
if not directory_path.exists():
logger.error(f"Directory '{docs_directory}' not found!")
logger.info(f"Please create the directory and add your documents: mkdir {docs_directory}")
return
# Supported file extensions
supported_extensions = {'.txt', '.md', '.json'}
logger.info(f"Scanning '{docs_directory}' for {', '.join(sorted(supported_extensions))} files...")
# Find all supported files
files_to_index = []
for ext in supported_extensions:
files_to_index.extend(directory_path.rglob(f"*{ext}"))
# Sort files for consistent processing
files_to_index.sort()
if not files_to_index:
logger.warning(f"No supported files found in '{docs_directory}' directory.")
logger.info("Add .txt, .md, or .json files to the directory and run this script again.")
return
logger.info(f"Found {len(files_to_index)} files to index:")
for file_path in files_to_index:
logger.info(f" - {file_path}")
# Index the documents
logger.info("\nStarting document indexing process...")
try:
result = rag_manager.index_personal_documents(docs_directory)
# Display results
logger.info("\n" + "="*50)
if result["success"]:
logger.info("✅ Document indexing completed successfully!")
logger.info(f" Indexed {result['indexed_count']} document chunks")
if result.get("failed_count", 0) > 0:
logger.warning(f" Failed to process {result['failed_count']} files")
else:
logger.error("❌ Document indexing failed!")
if "message" in result:
logger.error(f" Error: {result['message']}")
# Show final statistics
logger.info("\n" + "-"*30)
logger.info("Database Statistics:")
stats = rag_manager.get_stats()
if "error" not in stats:
for key, value in stats.items():
logger.info(f" {key}: {value}")
else:
logger.error(f" Failed to retrieve statistics: {stats['error']}")
logger.info("="*50)
except Exception as e:
logger.error(f"Failed to index documents: {e}")
return
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""
migrate_faiss_to_chroma.py
One-time migration of existing FAISS data to ChromaDB.
Migrates:
- Memory vectors: data/memory_vectors/ -> odysseus_memories collection
- RAG vectors: data/rag/ -> odysseus_rag collection
Usage:
python scripts/migrate_faiss_to_chroma.py
Requires: faiss-cpu, chromadb-client, and the embedding endpoint to be running.
"""
import json
import os
import sys
import logging
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("migrate")
def migrate_memories():
"""Migrate memory vectors from FAISS to ChromaDB."""
from src.chroma_client import get_chroma_client
from src.embeddings import get_embedding_client
from src.constants import DATA_DIR
ids_path = os.path.join(DATA_DIR, "memory_vectors", "ids.json")
memory_path = os.path.join(DATA_DIR, "memory.json")
if not os.path.exists(ids_path):
logger.info("No memory FAISS index found, skipping memory migration")
return
ids = json.loads(open(ids_path).read())
if not ids:
logger.info("Memory FAISS index is empty, skipping")
return
# Load memory texts
memories = {}
if os.path.exists(memory_path):
for mem in json.loads(open(memory_path).read()):
memories[mem.get("id", "")] = mem
embed = get_embedding_client()
if not embed:
logger.error("No embedding client available")
return
client = get_chroma_client()
collection = client.get_or_create_collection(
name="odysseus_memories",
metadata={"hnsw:space": "cosine"},
)
batch_ids, batch_texts, batch_metas = [], [], []
for mid in ids:
mem = memories.get(mid)
if not mem:
continue
text = mem.get("text", "").strip()
if not text:
continue
batch_ids.append(mid)
batch_texts.append(text)
batch_metas.append({"source": "memory", "category": mem.get("category", "fact")})
if batch_texts:
vecs = embed.encode(batch_texts, normalize_embeddings=True).tolist()
for i in range(0, len(batch_texts), 100):
collection.add(
ids=batch_ids[i:i+100],
embeddings=vecs[i:i+100],
documents=batch_texts[i:i+100],
metadatas=batch_metas[i:i+100],
)
logger.info(f"Migrated {len(batch_texts)} memories to ChromaDB")
else:
logger.info("No memory entries to migrate")
def migrate_rag():
"""Migrate RAG documents from FAISS DocStore to ChromaDB."""
from src.chroma_client import get_chroma_client
from src.embeddings import get_embedding_client
docs_path = os.path.join("data", "rag", "docs.json")
if not os.path.exists(docs_path):
logger.info("No RAG DocStore found, skipping RAG migration")
return
data = json.loads(open(docs_path).read())
ids = data.get("ids", [])
documents = data.get("documents", [])
metadatas = data.get("metadatas", [])
if not ids:
logger.info("RAG DocStore is empty, skipping")
return
embed = get_embedding_client()
if not embed:
logger.error("No embedding client available")
return
client = get_chroma_client()
collection = client.get_or_create_collection(
name="odysseus_rag",
metadata={"hnsw:space": "cosine"},
)
for i in range(0, len(ids), 100):
batch_ids = ids[i:i+100]
batch_docs = documents[i:i+100]
batch_metas = metadatas[i:i+100]
vecs = embed.encode(batch_docs, normalize_embeddings=True).tolist()
collection.add(
ids=batch_ids,
embeddings=vecs,
documents=batch_docs,
metadatas=batch_metas,
)
logger.info(f"Migrated {len(ids)} RAG chunks to ChromaDB")
if __name__ == "__main__":
from dotenv import load_dotenv
load_dotenv()
logger.info("Starting FAISS -> ChromaDB migration")
migrate_memories()
migrate_rag()
logger.info("Migration complete")

130
scripts/odysseus Executable file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""odysseus — one entrypoint for every odysseus-* CLI.
Discovers `scripts/odysseus-<name>` siblings and dispatches to them, the
same way `git` finds `git-foo`. Pure passthrough: any flags after the
subcommand name are forwarded as-is, so `odysseus mail list --pretty`
behaves exactly like `odysseus-mail list --pretty`.
odysseus # list every subcommand + short help
odysseus <name> # run odysseus-<name>
odysseus <name> <args...> # run odysseus-<name> <args...>
odysseus help # alias for `odysseus` (the listing)
odysseus help <name> # show the subcommand's own --help
odysseus --version # print version
Designed to live in `scripts/`. Symlink it onto `$PATH` (`ln -s
$PWD/scripts/odysseus ~/.local/bin/odysseus`) to use it without typing
the full path.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from pathlib import Path
VERSION = "0.1.0"
SCRIPTS_DIR = Path(__file__).resolve().parent
def _list_subcommands() -> list[Path]:
"""Find every executable `odysseus-*` next to this file (excluding
the dispatcher itself + any .bak / .pre-* migration leftovers)."""
out = []
for p in sorted(SCRIPTS_DIR.iterdir()):
if not p.is_file():
continue
if p.name == "odysseus":
continue
if not p.name.startswith("odysseus-"):
continue
if any(p.name.endswith(suf) for suf in (".bak", ".pre-cli-refactor", ".pyc")):
continue
if not os.access(p, os.X_OK):
continue
out.append(p)
return out
def _short_help(path: Path) -> str:
"""Pull the first non-blank line of the docstring (after the shebang)
as a 1-line description. Falls back to '(no description)'."""
try:
text = path.read_text()
except Exception:
return "(unreadable)"
m = re.search(r'"""(.*?)"""', text, re.DOTALL)
if not m:
return ""
body = m.group(1).strip()
if not body:
return ""
# First non-blank line, strip the leading "name — " noise.
first = next((ln for ln in body.splitlines() if ln.strip()), "")
first = re.sub(r"^odysseus-\w+\s*—\s*", "", first).strip()
return first
def _print_listing() -> None:
"""`odysseus` with no args (or `odysseus help`) — print the table."""
sys.stdout.write(f"odysseus {VERSION} — every feature, on the shell.\n\n")
sys.stdout.write("Usage: odysseus <subcommand> [args...]\n\n")
sys.stdout.write("Available subcommands:\n")
subs = _list_subcommands()
width = max((len(p.name[len("odysseus-"):]) for p in subs), default=10)
for p in subs:
name = p.name[len("odysseus-"):]
desc = _short_help(p)
sys.stdout.write(f" {name:<{width + 2}}{desc}\n")
sys.stdout.write(
"\nRun `odysseus help <subcommand>` for that tool's own --help.\n"
)
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if not argv or argv[0] in ("-h", "--help", "help") and len(argv) == 1:
_print_listing()
return 0
if argv[0] in ("-v", "--version"):
sys.stdout.write(f"odysseus {VERSION}\n")
return 0
if argv[0] == "help":
# `odysseus help foo` → `odysseus-foo --help`
if len(argv) < 2:
_print_listing()
return 0
sub = SCRIPTS_DIR / f"odysseus-{argv[1]}"
if not sub.exists():
sys.stderr.write(f"odysseus: unknown subcommand {argv[1]!r}\n")
return 1
return subprocess.call([str(sub), "--help"])
# `odysseus foo ...` → exec `odysseus-foo ...` under the project venv.
name = argv[0]
sub = SCRIPTS_DIR / f"odysseus-{name}"
if not sub.exists():
sys.stderr.write(
f"odysseus: unknown subcommand {name!r}. "
f"Try `odysseus help` to see available ones.\n"
)
return 1
# Run the subcommand with the project's venv Python so dependencies
# (bcrypt, sqlalchemy, fastapi, ...) resolve. Fall back to whatever
# `python` is on PATH if no venv is found — useful in container
# installs that wire the deps system-wide.
repo_root = SCRIPTS_DIR.parent
venv_python = repo_root / "venv" / "bin" / "python"
py = str(venv_python) if venv_python.exists() else sys.executable
os.execv(py, [py, str(sub)] + argv[1:])
return 0 # unreachable
if __name__ == "__main__":
sys.exit(main())

218
scripts/odysseus-backup Executable file
View File

@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""odysseus-backup — snapshot + restore of the data directory.
Backs up everything the app keeps under `data/`: the SQLite DB, the
Fernet key, JSON state files, RAG indexes, personal docs, attachments,
WhatsApp session, etc. Output is a gzip tarball — composable with cron
+ scp + s3cmd. The backup uses `sqlite3 .backup` for the DB so the
app can keep running during the snapshot.
odysseus-backup snapshot # → backups/YYYY-MM-DD-HHMMSS.tar.gz
odysseus-backup snapshot --out /mnt/nas/x.tgz
odysseus-backup list # entries in backups/
odysseus-backup restore PATH [--yes] # overwrite current data/ from a tarball
odysseus-backup verify PATH # tarball integrity check (no extract)
Restore is destructive: it overwrites `data/` in place. Always pass
`--yes` so a typo can't nuke your live state.
"""
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, sqlite3, subprocess, sys, tarfile, tempfile
from datetime import datetime
from pathlib import Path
_DATA_DIR = _REPO_ROOT / "data"
_BACKUP_DIR = _REPO_ROOT / "backups"
# Stuff inside data/ that we explicitly skip — anything we can re-derive
# from the SQLite DB + JSON state. Keeps the tarball small.
_SKIP_PATTERNS = {
"mail-attachments", # cached IMAP attachment extractions
"deep_research", # research runs are large; back up explicitly via --include-research
"personal_uploads", # uploaded files; usually wanted, included by default actually
}
def _sqlite_safe_copy(src: Path, dst: Path) -> None:
"""Use SQLite's `.backup` API instead of a file copy so a write
in-flight doesn't corrupt the snapshot. Falls back to plain copy
if the file isn't a SQLite DB."""
try:
src_conn = sqlite3.connect(str(src))
dst_conn = sqlite3.connect(str(dst))
with dst_conn:
src_conn.backup(dst_conn)
src_conn.close()
dst_conn.close()
except Exception:
# Not a SQLite DB or backup unsupported — fall back.
dst.write_bytes(src.read_bytes())
def cmd_snapshot(args):
"""Write a tar.gz of the entire data/ directory.
SQLite databases are dumped via .backup() into a temp file before
being added to the tar, so a running web app can't corrupt the
snapshot. Everything else is copied as-is."""
if not _DATA_DIR.is_dir():
fail(f"no data dir at {_DATA_DIR}")
_BACKUP_DIR.mkdir(parents=True, exist_ok=True)
out_path = Path(args.out) if args.out else (
_BACKUP_DIR / f"odysseus-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.tar.gz"
)
out_path.parent.mkdir(parents=True, exist_ok=True)
sqlite_dbs = [p for p in _DATA_DIR.rglob("*.db") if p.is_file()]
files_added = 0
total_bytes = 0
with tempfile.TemporaryDirectory() as tmp_str:
tmp = Path(tmp_str)
# Snapshot SQLite DBs to the temp dir first.
db_map: dict[Path, Path] = {}
for db in sqlite_dbs:
rel = db.relative_to(_DATA_DIR)
staged = tmp / rel
staged.parent.mkdir(parents=True, exist_ok=True)
_sqlite_safe_copy(db, staged)
db_map[db] = staged
with tarfile.open(out_path, "w:gz") as tar:
for p in sorted(_DATA_DIR.rglob("*")):
if not p.is_file():
continue
rel = p.relative_to(_DATA_DIR.parent)
# Skip user-asked-to-skip categories
if not args.include_research and "deep_research" in rel.parts:
continue
if not args.include_attachments and "mail-attachments" in rel.parts:
continue
# Substitute SQLite snapshots for the live files
source = db_map.get(p, p)
tar.add(source, arcname=str(rel))
files_added += 1
try:
total_bytes += source.stat().st_size
except Exception:
pass
emit({
"ok": True,
"path": str(out_path),
"files": files_added,
"uncompressed_bytes": total_bytes,
"compressed_bytes": out_path.stat().st_size,
"ratio": round(out_path.stat().st_size / max(total_bytes, 1), 4),
"included_research": bool(args.include_research),
"included_attachments": bool(args.include_attachments),
}, args)
def cmd_list(args):
"""List entries in `backups/`. Most recent first."""
if not _BACKUP_DIR.is_dir():
emit([], args)
return
entries = []
for p in sorted(_BACKUP_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if not p.is_file():
continue
entries.append({
"path": str(p),
"name": p.name,
"bytes": p.stat().st_size,
"modified": datetime.fromtimestamp(p.stat().st_mtime).isoformat(),
})
emit(entries, args)
def cmd_verify(args):
"""Open the tarball read-only and walk its members — confirms
integrity without extracting anything."""
path = Path(args.path)
if not path.exists():
fail(f"no file at {path}")
try:
with tarfile.open(path, "r:gz") as tar:
members = tar.getmembers()
except (tarfile.TarError, OSError) as e:
fail(f"tarball is corrupt: {e}")
emit({
"ok": True,
"path": str(path),
"members": len(members),
"first": members[0].name if members else None,
"last": members[-1].name if members else None,
}, args)
def cmd_restore(args):
"""Overwrite `data/` from a tarball. Destructive; requires --yes."""
path = Path(args.path)
if not path.exists():
fail(f"no file at {path}")
if not args.yes:
fail("restore is destructive — pass --yes to confirm overwriting data/")
# Sanity check: tarball entries must all be under `data/`. If anyone
# crafted a malicious tarball with `../etc/passwd`, refuse.
with tarfile.open(path, "r:gz") as tar:
for m in tar.getmembers():
if m.name.startswith("/") or ".." in Path(m.name).parts:
fail(f"refusing tarball with absolute/parent path: {m.name!r}")
if not m.name.startswith("data/") and m.name != "data":
fail(f"refusing tarball with entry outside data/: {m.name!r}")
# Save a safety copy of current data/ before extracting.
if _DATA_DIR.exists():
stash = _REPO_ROOT / f"data.before-restore-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
os.rename(_DATA_DIR, stash)
try:
tar.extractall(path=_REPO_ROOT)
except Exception as e:
fail(f"extract failed: {e}")
emit({
"ok": True,
"restored_from": str(path),
"previous_data_stashed_at": str(stash) if _DATA_DIR.exists() else None,
}, args)
def _build_parser():
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--pretty", action="store_true")
p = argparse.ArgumentParser(prog="odysseus-backup", parents=[common])
sub = p.add_subparsers(dest="cmd", required=True)
ps = sub.add_parser("snapshot", parents=[common])
ps.add_argument("--out", help="output path (default: backups/<timestamp>.tar.gz)")
ps.add_argument("--include-research", action="store_true",
help="include data/deep_research/ (skipped by default; large)")
ps.add_argument("--include-attachments", action="store_true",
help="include data/mail-attachments/ (skipped by default; re-derivable)")
ps.set_defaults(func=cmd_snapshot)
pl = sub.add_parser("list", parents=[common])
pl.set_defaults(func=cmd_list)
pv = sub.add_parser("verify", parents=[common])
pv.add_argument("path")
pv.set_defaults(func=cmd_verify)
pr = sub.add_parser("restore", parents=[common])
pr.add_argument("path")
pr.add_argument("--yes", action="store_true",
help="confirm overwriting current data/")
pr.set_defaults(func=cmd_restore)
return p
if __name__ == "__main__":
sys.exit(run(_build_parser()))

249
scripts/odysseus-calendar Executable file
View File

@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""odysseus-calendar — Unix-style CLI for the calendar feature.
Reads the same SQLite the web UI uses (`data/app.db`). Output is JSON on
stdout, errors on stderr, non-zero exit on failure. Composable:
odysseus-calendar list --start 2026-05-01 --end 2026-05-31 \\
| jq -r '.[] | .dtstart + "\\t" + .summary'
odysseus-calendar calendars | jq -r '.[].name'
Subcommands:
list List events in a date range (optionally per-calendar)
show Show one event by UID
calendars List configured calendars
create Create a new event
delete Delete an event by UID
"""
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
import json
import logging
import os
import sys
import uuid
from datetime import datetime, timedelta
from pathlib import Path
def quiet_logs() -> None:
level_name = os.environ.get("LOG_LEVEL", "WARNING").upper()
level = getattr(logging, level_name, logging.WARNING)
root = logging.getLogger()
root.setLevel(level)
for handler in root.handlers:
handler.setLevel(level)
quiet_logs()
try:
from core.database import SessionLocal, CalendarCal, CalendarEvent
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 fail(msg: str, code: int = 1) -> None:
sys.stderr.write(f"error: {msg}\n")
sys.exit(code)
def _parse_dt(s: str) -> datetime:
"""Accept either `YYYY-MM-DD` (treated as midnight local) or full
ISO 8601 with optional `Z`/offset."""
if len(s) == 10:
return datetime.fromisoformat(s + "T00:00:00")
return datetime.fromisoformat(s.replace("Z", "+00:00"))
def _serialize_event(ev: "CalendarEvent") -> dict:
return {
"uid": ev.uid,
"calendar_id": ev.calendar_id,
"calendar_name": ev.calendar.name if ev.calendar else "",
"summary": ev.summary,
"description": ev.description or "",
"location": ev.location or "",
"dtstart": ev.dtstart.isoformat() + ("Z" if ev.is_utc else "") if ev.dtstart else "",
"dtend": ev.dtend.isoformat() + ("Z" if ev.is_utc else "") if ev.dtend else "",
"all_day": bool(ev.all_day),
"is_utc": bool(ev.is_utc),
"rrule": ev.rrule or "",
"color": ev.color or "",
"status": ev.status or "",
"importance": ev.importance or "",
"event_type": ev.event_type or "",
}
# ─── list ────────────────────────────────────────────────────────────
def cmd_list(args) -> None:
"""List events in a date range. Defaults to next 30 days from today."""
start = _parse_dt(args.start) if args.start else datetime.now()
end = _parse_dt(args.end) if args.end else (start + timedelta(days=30))
db = SessionLocal()
try:
q = db.query(CalendarEvent).filter(
CalendarEvent.dtstart >= start,
CalendarEvent.dtstart < end,
)
if args.calendar:
cal = db.query(CalendarCal).filter(CalendarCal.name == args.calendar).first()
if not cal:
fail(f"no calendar named {args.calendar!r}")
q = q.filter(CalendarEvent.calendar_id == cal.id)
q = q.order_by(CalendarEvent.dtstart.asc()).limit(args.limit)
emit([_serialize_event(e) for e in q.all()], args)
finally:
db.close()
# ─── show ────────────────────────────────────────────────────────────
def cmd_show(args) -> None:
db = SessionLocal()
try:
ev = db.get(CalendarEvent, args.uid)
if not ev:
fail(f"no event with uid {args.uid!r}")
emit(_serialize_event(ev), args)
finally:
db.close()
# ─── calendars ───────────────────────────────────────────────────────
def cmd_calendars(args) -> None:
db = SessionLocal()
try:
cals = db.query(CalendarCal).order_by(CalendarCal.name.asc()).all()
emit([
{
"id": c.id,
"name": c.name,
"color": c.color or "",
"source": c.source or "local",
"event_count": len(c.events),
} for c in cals
], args)
finally:
db.close()
# ─── create ──────────────────────────────────────────────────────────
def cmd_create(args) -> None:
"""Create an event. --start/--end accept YYYY-MM-DD or ISO 8601.
--calendar selects by name; defaults to the first available."""
dtstart = _parse_dt(args.start)
dtend = _parse_dt(args.end) if args.end else (dtstart + timedelta(hours=1))
db = SessionLocal()
try:
cal_q = db.query(CalendarCal)
if args.calendar:
cal = cal_q.filter(CalendarCal.name == args.calendar).first()
if not cal:
fail(f"no calendar named {args.calendar!r}")
else:
cal = cal_q.order_by(CalendarCal.created_at.asc()).first()
if not cal:
fail("no calendars exist; create one in the web UI first")
ev = CalendarEvent(
uid=str(uuid.uuid4()),
calendar_id=cal.id,
summary=args.title,
description=args.description or "",
location=args.location or "",
dtstart=dtstart,
dtend=dtend,
all_day=bool(args.all_day),
is_utc=False,
importance=args.importance,
event_type=args.event_type or None,
)
db.add(ev)
db.commit()
db.refresh(ev)
emit(_serialize_event(ev), args)
finally:
db.close()
# ─── delete ──────────────────────────────────────────────────────────
def cmd_delete(args) -> None:
db = SessionLocal()
try:
ev = db.get(CalendarEvent, args.uid)
if not ev:
fail(f"no event with uid {args.uid!r}")
snapshot = _serialize_event(ev)
db.delete(ev)
db.commit()
emit({"ok": True, "deleted": snapshot}, args)
finally:
db.close()
# ─── argparse ────────────────────────────────────────────────────────
def _build_parser() -> argparse.ArgumentParser:
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--pretty", action="store_true", help="Pretty-print JSON")
p = argparse.ArgumentParser(
prog="odysseus-calendar",
description="Shell-friendly wrapper around the Odysseus calendar.",
parents=[common],
)
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", help="list events in a date range", parents=[common])
pl.add_argument("--start", help="YYYY-MM-DD or ISO datetime (default: today)")
pl.add_argument("--end", help="YYYY-MM-DD or ISO datetime (default: start + 30 days)")
pl.add_argument("--calendar", help="filter by calendar name")
pl.add_argument("--limit", type=int, default=100)
pl.set_defaults(func=cmd_list)
psh = sub.add_parser("show", help="show one event by UID", parents=[common])
psh.add_argument("uid")
psh.set_defaults(func=cmd_show)
pc = sub.add_parser("calendars", help="list configured calendars", parents=[common])
pc.set_defaults(func=cmd_calendars)
pcr = sub.add_parser("create", help="create an event", parents=[common])
pcr.add_argument("--title", required=True)
pcr.add_argument("--start", required=True, help="YYYY-MM-DD or ISO datetime")
pcr.add_argument("--end", help="YYYY-MM-DD or ISO datetime (default: start + 1h)")
pcr.add_argument("--calendar", help="calendar name (default: first available)")
pcr.add_argument("--description", default="")
pcr.add_argument("--location", default="")
pcr.add_argument("--all-day", action="store_true")
pcr.add_argument("--importance", choices=["low", "normal", "high", "critical"], default="normal")
pcr.add_argument("--event-type", choices=["work", "personal", "health", "travel", "meal", "social", "admin", "other"])
pcr.set_defaults(func=cmd_create)
pd = sub.add_parser("delete", help="delete an event by UID", parents=[common])
pd.add_argument("uid")
pd.set_defaults(func=cmd_delete)
return p
if __name__ == "__main__":
sys.exit(run(_build_parser()))

143
scripts/odysseus-contacts Executable file
View File

@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""odysseus-contacts — Unix-style CLI for the contacts feature.
Talks to the same CardDAV server the web UI does, using credentials
from `data/settings.json` (set via the Settings → Connections panel).
Output is JSON on stdout, errors on stderr, non-zero exit on failure.
odysseus-contacts list | jq -r '.[] | .name + "\\t" + .email'
odysseus-contacts search alice
odysseus-contacts add --name "Alice Doe" --email alice@example.com
Subcommands:
list List all contacts
search Filter contacts by case-insensitive substring match
add Add a new contact (name + email)
config Show the current CardDAV config (URL/user; password masked)
"""
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
import json
import logging
import os
import sys
from pathlib import Path
def quiet_logs() -> None:
level_name = os.environ.get("LOG_LEVEL", "WARNING").upper()
level = getattr(logging, level_name, logging.WARNING)
root = logging.getLogger()
root.setLevel(level)
for handler in root.handlers:
handler.setLevel(level)
quiet_logs()
try:
from routes.contacts_routes import (
_get_carddav_config,
_fetch_contacts,
_create_contact,
)
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 fail(msg: str, code: int = 1) -> None:
sys.stderr.write(f"error: {msg}\n")
sys.exit(code)
# ─── list ────────────────────────────────────────────────────────────
def cmd_list(args) -> None:
cfg = _get_carddav_config()
if not cfg["url"]:
fail("CardDAV not configured. Set carddav_url/username/password in the web UI.")
contacts = _fetch_contacts(force=args.refresh)
emit(contacts, args)
# ─── search ──────────────────────────────────────────────────────────
def cmd_search(args) -> None:
cfg = _get_carddav_config()
if not cfg["url"]:
fail("CardDAV not configured.")
q = args.query.lower()
contacts = _fetch_contacts()
matches = [
c for c in contacts
if q in (c.get("name") or "").lower() or q in (c.get("email") or "").lower()
]
emit(matches, args)
# ─── add ─────────────────────────────────────────────────────────────
def cmd_add(args) -> None:
cfg = _get_carddav_config()
if not cfg["url"]:
fail("CardDAV not configured.")
ok = _create_contact(args.name, args.email)
if not ok:
fail("CardDAV PUT failed (see LOG_LEVEL=DEBUG for detail)")
emit({"ok": True, "name": args.name, "email": args.email}, args)
# ─── config ──────────────────────────────────────────────────────────
def cmd_config(args) -> None:
cfg = _get_carddav_config()
safe = dict(cfg)
if safe.get("password"):
safe["password"] = "***"
emit(safe, args)
# ─── argparse ────────────────────────────────────────────────────────
def _build_parser() -> argparse.ArgumentParser:
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--pretty", action="store_true", help="Pretty-print JSON")
p = argparse.ArgumentParser(
prog="odysseus-contacts",
description="Shell-friendly wrapper around the Odysseus contacts (CardDAV) feature.",
parents=[common],
)
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", help="list all contacts", parents=[common])
pl.add_argument("--refresh", action="store_true", help="bypass cache and re-fetch")
pl.set_defaults(func=cmd_list)
ps = sub.add_parser("search", help="filter by name/email substring", parents=[common])
ps.add_argument("query")
ps.set_defaults(func=cmd_search)
pa = sub.add_parser("add", help="add a new contact", parents=[common])
pa.add_argument("--name", required=True)
pa.add_argument("--email", required=True)
pa.set_defaults(func=cmd_add)
pc = sub.add_parser("config", help="show CardDAV config (password masked)", parents=[common])
pc.set_defaults(func=cmd_config)
return p
if __name__ == "__main__":
sys.exit(run(_build_parser()))

463
scripts/odysseus-cookbook Executable file
View File

@@ -0,0 +1,463 @@
#!/usr/bin/env python3
"""odysseus-cookbook — shell wrapper for the cookbook feature.
The web UI orchestrates HuggingFace model downloads + local serving
through tmux sessions and writes its bookkeeping to
`data/cookbook_state.json`. This CLI exposes the same operations on
the shell so they can be cron'd, piped, or scripted:
odysseus-cookbook list # active downloads + servers
odysseus-cookbook gpus # nvidia-smi per-GPU JSON
odysseus-cookbook cached # local HF cache snapshot
odysseus-cookbook hf-latest --vram-gb 24 # trending HF models that fit
odysseus-cookbook download Qwen/Qwen3-8B # fire off `hf download` in tmux
odysseus-cookbook kill cookbook-abc123 # tmux kill-session
Reads/writes the same `data/cookbook_state.json` the web UI uses, so
state stays in sync. Output is JSON on stdout, errors on stderr,
non-zero exit on failure.
"""
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
import json
import logging
import os
import re
import subprocess
import sys
import urllib.request
import urllib.parse
import uuid
from pathlib import Path
_DATA_DIR = Path(os.environ.get("DATA_DIR", str(_REPO_ROOT / "data")))
_STATE_PATH = _DATA_DIR / "cookbook_state.json"
# Mirror routes/shell_routes.TMUX_LOG_DIR — don't import it because that pulls
# the whole web app into the process. Match its definition instead.
import tempfile
_TMUX_LOG_DIR = Path(tempfile.gettempdir()) / "odysseus-tmux"
def fail(msg: str, code: int = 1) -> None:
sys.stderr.write(f"error: {msg}\n")
sys.exit(code)
def _tmux_sessions() -> list[str]:
"""Return active tmux session names, or [] if tmux isn't installed."""
try:
out = subprocess.run(
["tmux", "list-sessions", "-F", "#S"],
capture_output=True, text=True, timeout=5,
)
if out.returncode != 0:
return []
return [s.strip() for s in out.stdout.splitlines() if s.strip()]
except FileNotFoundError:
return []
except Exception:
return []
def _read_state() -> dict:
if not _STATE_PATH.exists():
return {}
try:
return json.loads(_STATE_PATH.read_text())
except Exception:
return {}
# ─── list ────────────────────────────────────────────────────────────
def cmd_list(args) -> None:
"""Active tmux sessions + cookbook state, joined.
Output: {state, sessions, cookbook_sessions} where cookbook_sessions
is the subset of tmux sessions whose name starts with `cookbook-`."""
sessions = _tmux_sessions()
cookbook = [s for s in sessions if s.startswith("cookbook-")]
emit({
"state": _read_state(),
"all_tmux_sessions": sessions,
"cookbook_sessions": cookbook,
}, args)
# ─── gpus ────────────────────────────────────────────────────────────
def cmd_gpus(args) -> None:
"""Same shape the web UI gets — index/name/free_mb/total_mb/used_mb/
util_pct/uuid. Returns `[]` with an `error` field if nvidia-smi is
missing (laptop / CPU-only box). Pass `--host user@box` to run over
SSH against a remote machine."""
query = "nvidia-smi --query-gpu=index,name,memory.free,memory.total,memory.used,utilization.gpu,uuid --format=csv,noheader,nounits"
prefix = _ssh_prefix(args.host, args.ssh_port)
cmd = prefix + (query.split() if not prefix else [query])
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
except FileNotFoundError:
msg = "ssh not found" if prefix else "nvidia-smi not found"
emit({"ok": False, "error": msg, "gpus": []}, args)
return
if out.returncode != 0:
emit({"ok": False, "error": out.stderr.strip()[:200], "gpus": []}, args)
return
gpus = []
for line in out.stdout.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) < 7:
continue
try:
idx, name, free_mb, total_mb, used_mb, util, gpu_uuid = parts[:7]
total_i, free_i = int(total_mb), int(free_mb)
gpus.append({
"index": int(idx),
"name": name,
"free_mb": free_i,
"total_mb": total_i,
"used_mb": int(used_mb),
"util_pct": int(util),
"uuid": gpu_uuid,
"busy": (free_i / total_i) < 0.5 if total_i else False,
})
except (ValueError, ZeroDivisionError):
continue
emit({"ok": True, "gpus": gpus}, args)
# ─── cached ──────────────────────────────────────────────────────────
def cmd_cached(args) -> None:
"""List cached HuggingFace models. Walks ~/.cache/huggingface/hub
(or $HF_HOME) and returns directory names with size summaries.
Cheap version of the route's full-scan helper — good enough for a
`which models do I already have` glance."""
hf_home = Path(os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface"))
hub = hf_home / "hub"
if not hub.is_dir():
emit({"models": [], "hub_path": str(hub), "note": "no hub cache yet"}, args)
return
models = []
for entry in sorted(hub.iterdir()):
if not entry.is_dir():
continue
# Hub layout: `models--<org>--<repo>` becomes `<org>/<repo>`.
if entry.name.startswith("models--"):
repo = entry.name[len("models--"):].replace("--", "/")
elif entry.name.startswith("datasets--"):
repo = "datasets/" + entry.name[len("datasets--"):].replace("--", "/")
else:
repo = entry.name
size = 0
try:
for f in entry.rglob("*"):
if f.is_file() and not f.is_symlink():
size += f.stat().st_size
except Exception:
pass
models.append({"repo": repo, "path": str(entry), "size_bytes": size})
emit({"models": models, "hub_path": str(hub)}, args)
# ─── hf-latest ───────────────────────────────────────────────────────
def cmd_hf_latest(args) -> None:
"""Trending HF models, optionally filtered by VRAM-at-fp16 fit.
Mirrors `/api/cookbook/hf-latest` so cron jobs that pre-pull
"models that fit on my box this week" can use the same filter."""
pool_size = max(args.limit * 15, 100)
url = (
"https://huggingface.co/api/models"
f"?sort=trendingScore&direction=-1&limit={pool_size}&filter={urllib.parse.quote(args.pipeline)}"
)
try:
with urllib.request.urlopen(url, timeout=15) as resp:
raw = json.loads(resp.read().decode("utf-8"))
except Exception as e:
fail(f"HF API request failed: {e}")
def _est_vram_fp16(repo_id: str) -> float | None:
m = re.search(r'[-_/](\d+(?:\.\d+)?)\s*[Bb](?![a-zA-Z])', repo_id)
if not m:
return None
params_b = float(m.group(1))
return params_b * 2 # fp16 = 2 bytes/param
out = []
for m in raw:
rid = m.get("id") or m.get("modelId") or ""
if not rid:
continue
vram = _est_vram_fp16(rid)
if args.vram_gb > 0 and vram is not None and vram > args.vram_gb:
continue
out.append({
"id": rid,
"downloads": m.get("downloads", 0),
"likes": m.get("likes", 0),
"trendingScore": m.get("trendingScore"),
"pipeline_tag": m.get("pipeline_tag", ""),
"est_vram_fp16_gb": vram,
})
if len(out) >= args.limit:
break
emit({"models": out, "vram_gb_filter": args.vram_gb}, args)
# ─── download ────────────────────────────────────────────────────────
def cmd_download(args) -> None:
"""Start `hf download <repo>` in a detached tmux session. Returns
the session ID so callers can `tail` the log or `kill` later.
Pass `--host user@box` to run the download on a remote machine
over SSH. The remote needs `tmux` and `hf` installed; the local
side just gets a session-id back."""
if not re.fullmatch(r"[\w.-]+/[\w.-]+", args.repo):
fail(f"invalid repo id {args.repo!r} — expected `org/name`")
session_id = f"cookbook-dl-{uuid.uuid4().hex[:8]}"
cmd_parts = ["hf", "download", args.repo]
if args.include:
cmd_parts += ["--include", args.include]
if args.revision:
cmd_parts += ["--revision", args.revision]
if args.host:
# Remote — let the remote shell decide log location.
remote_log = f"/tmp/odysseus-tmux/{session_id}.log"
hf_cmd = " ".join(map(_shell_quote, cmd_parts))
remote_shell_cmd = (
f"mkdir -p /tmp/odysseus-tmux && "
f"tmux new-session -d -s {_shell_quote(session_id)} "
f"bash -lc {_shell_quote(f'{hf_cmd} 2>&1 | tee {remote_log}; echo DONE')}"
)
ssh_argv = _ssh_prefix(args.host, args.ssh_port) + [remote_shell_cmd]
try:
out = subprocess.run(ssh_argv, capture_output=True, text=True, timeout=20)
except FileNotFoundError:
fail("ssh not installed")
if out.returncode != 0:
fail(f"remote tmux launch failed: {out.stderr.strip() or out.stdout.strip()}")
emit({
"ok": True,
"session_id": session_id,
"repo": args.repo,
"host": args.host,
"remote_log_path": remote_log,
"tail_cmd": f"ssh {args.host} tail -f {remote_log}",
"kill_cmd": f"odysseus-cookbook kill {session_id} --host {args.host}",
}, args)
return
_TMUX_LOG_DIR.mkdir(parents=True, exist_ok=True)
log_path = _TMUX_LOG_DIR / f"{session_id}.log"
shell_cmd = " ".join(map(_shell_quote, cmd_parts)) + f" 2>&1 | tee {_shell_quote(str(log_path))}; echo DONE"
try:
subprocess.run(
["tmux", "new-session", "-d", "-s", session_id, "bash", "-lc", shell_cmd],
check=True, capture_output=True, text=True, timeout=10,
)
except FileNotFoundError:
fail("tmux not installed — can't run background sessions from CLI")
except subprocess.CalledProcessError as e:
fail(f"tmux failed: {e.stderr or e.stdout}")
emit({
"ok": True,
"session_id": session_id,
"repo": args.repo,
"log_path": str(log_path),
"tail_cmd": f"tail -f {log_path}",
"kill_cmd": f"odysseus-cookbook kill {session_id}",
}, args)
def _shell_quote(s: str) -> str:
"""Minimal POSIX-shell quoting — wraps `s` in single quotes and
escapes any embedded single quotes."""
return "'" + s.replace("'", "'\\''") + "'"
# ─── serve ───────────────────────────────────────────────────────────
def cmd_serve(args) -> None:
"""Run an arbitrary serve command in a detached tmux session.
Deliberately not opinionated about flags — the web UI handles
platform-specific template generation. For the CLI you pass the full
serve command via `--cmd`. Common patterns:
odysseus-cookbook serve qwen3-8b --cmd 'python -m vllm.entrypoints.openai.api_server --model Qwen/Qwen3-8B --port 8000'
odysseus-cookbook serve sdxl --cmd 'python scripts/diffusion_server.py --model stabilityai/sdxl --port 8006'
"""
if not args.cmd or not args.cmd.strip():
fail("--cmd is required and must be a non-empty serve command")
_TMUX_LOG_DIR.mkdir(parents=True, exist_ok=True)
safe_name = re.sub(r"[^\w.-]", "-", args.name)[:32] or "anon"
session_id = f"serve-{safe_name}-{uuid.uuid4().hex[:6]}"
log_path = _TMUX_LOG_DIR / f"{session_id}.log"
shell_cmd = f"{args.cmd} 2>&1 | tee {_shell_quote(str(log_path))}; echo DONE"
try:
subprocess.run(
["tmux", "new-session", "-d", "-s", session_id, "bash", "-lc", shell_cmd],
check=True, capture_output=True, text=True, timeout=10,
)
except FileNotFoundError:
fail("tmux not installed")
except subprocess.CalledProcessError as e:
fail(f"tmux failed: {e.stderr or e.stdout}")
emit({
"ok": True,
"session_id": session_id,
"log_path": str(log_path),
"cmd": args.cmd,
"tail_cmd": f"tail -f {log_path}",
"kill_cmd": f"odysseus-cookbook kill {session_id}",
}, args)
# ─── state set ───────────────────────────────────────────────────────
def cmd_state_set(args) -> None:
"""Write JSON from stdin to data/cookbook_state.json. Atomic via
a temp-file + rename so a partial write can't corrupt the file.
Before overwriting we copy the previous state to .bak — if you ever
nuke your live state by piping the wrong thing into stdin, restore
with `cp data/cookbook_state.json.bak data/cookbook_state.json`."""
data = sys.stdin.read()
if not data.strip():
fail("expected JSON on stdin")
try:
obj = json.loads(data)
except json.JSONDecodeError as e:
fail(f"invalid JSON on stdin: {e}")
_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
# Backup the existing state — undo button if a bad pipe clobbers it.
if _STATE_PATH.exists():
bak = _STATE_PATH.with_suffix(_STATE_PATH.suffix + ".bak")
try:
bak.write_bytes(_STATE_PATH.read_bytes())
except Exception:
pass
tmp = _STATE_PATH.with_suffix(_STATE_PATH.suffix + ".tmp")
tmp.write_text(json.dumps(obj, indent=2, ensure_ascii=False))
tmp.replace(_STATE_PATH)
emit({"ok": True, "path": str(_STATE_PATH), "bytes": len(data)}, args)
# ─── remote helpers ──────────────────────────────────────────────────
def _ssh_prefix(host: str | None, port: str | None) -> list[str]:
"""Return the ssh argv prefix when --host is given, else []."""
if not host:
return []
cmd = ["ssh"]
if port:
cmd += ["-p", str(port)]
cmd += ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", host]
return cmd
# ─── kill ────────────────────────────────────────────────────────────
def cmd_kill(args) -> None:
"""Terminate a tmux session by name. Idempotent — exits 0 even if
the session is already gone. Pass `--host user@box` to kill a
remote session created via `download --host`."""
base = ["tmux", "kill-session", "-t", args.session]
cmd = _ssh_prefix(args.host, args.ssh_port) + base if args.host else base
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
except FileNotFoundError:
fail("tmux not installed" if not args.host else "ssh not installed")
already_gone = out.returncode != 0 and "can't find session" in (out.stderr or "").lower()
emit({
"ok": True,
"session": args.session,
"host": args.host or "local",
"was_running": out.returncode == 0,
"already_gone": already_gone,
}, args)
# ─── state ───────────────────────────────────────────────────────────
def cmd_state(args) -> None:
"""Dump the raw cookbook state file (the web UI's localStorage-y
JSON for active downloads/servers)."""
emit(_read_state(), args)
# ─── argparse ────────────────────────────────────────────────────────
def _build_parser() -> argparse.ArgumentParser:
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--pretty", action="store_true", help="Pretty-print JSON")
p = argparse.ArgumentParser(
prog="odysseus-cookbook",
description="Shell-friendly wrapper around the Odysseus cookbook (model download + serve).",
parents=[common],
)
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", help="active tmux sessions + cookbook state", parents=[common])
pl.set_defaults(func=cmd_list)
pg = sub.add_parser("gpus", help="per-GPU free/used VRAM (nvidia-smi)", parents=[common])
pg.add_argument("--host", help="run nvidia-smi over SSH against this host")
pg.add_argument("--ssh-port", help="SSH port (default: 22)")
pg.set_defaults(func=cmd_gpus)
pc = sub.add_parser("cached", help="HuggingFace local cache snapshot", parents=[common])
pc.set_defaults(func=cmd_cached)
ph = sub.add_parser("hf-latest", help="trending HF models, VRAM-filtered", parents=[common])
ph.add_argument("--vram-gb", type=float, default=0, help="filter to models that fit (0 = all)")
ph.add_argument("--limit", type=int, default=10)
ph.add_argument("--pipeline", default="text-generation",
help="HF pipeline_tag (text-generation, text-to-image, etc.)")
ph.set_defaults(func=cmd_hf_latest)
pd = sub.add_parser("download", help="`hf download <repo>` in a tmux session", parents=[common])
pd.add_argument("repo", help="HF repo id, e.g. 'Qwen/Qwen3-8B'")
pd.add_argument("--include", help="glob filter for specific files")
pd.add_argument("--revision", help="git ref / branch / tag")
pd.add_argument("--host", help="run on a remote machine over SSH")
pd.add_argument("--ssh-port", help="SSH port (default: 22)")
pd.set_defaults(func=cmd_download)
pse = sub.add_parser("serve", help="run an arbitrary serve cmd in tmux", parents=[common])
pse.add_argument("name", help="short label for the session (e.g. 'qwen3-8b')")
pse.add_argument("--cmd", required=True, help="full shell command to run")
pse.set_defaults(func=cmd_serve)
pk = sub.add_parser("kill", help="tmux kill-session by name", parents=[common])
pk.add_argument("session", help="session name, e.g. 'cookbook-dl-abc123'")
pk.add_argument("--host", help="kill a remote session")
pk.add_argument("--ssh-port", help="SSH port")
pk.set_defaults(func=cmd_kill)
pst = sub.add_parser("state", help="dump cookbook_state.json", parents=[common])
pst.set_defaults(func=cmd_state)
pss = sub.add_parser("state-set", help="write JSON from stdin into cookbook_state.json", parents=[common])
pss.set_defaults(func=cmd_state_set)
return p
if __name__ == "__main__":
sys.exit(run(_build_parser()))

199
scripts/odysseus-docs Executable file
View File

@@ -0,0 +1,199 @@
#!/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 _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": len(d.current_content or ""),
"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": len(v.content or ""),
} 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()))

165
scripts/odysseus-gallery Executable file
View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""odysseus-gallery — shell wrapper for the photo / image gallery.
Read-only by default (upload + edit operations need multipart payloads
that are awkward at the shell). Filters images by tag, prompt, favorite,
album, and surfaces EXIF metadata.
odysseus-gallery list --limit 20 --favorites
odysseus-gallery show IMAGE_ID
odysseus-gallery albums | jq -r '.[] | .name'
odysseus-gallery search "sunset" # matches prompt + tags
odysseus-gallery delete IMAGE_ID # soft-delete (is_active=False)
"""
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, GalleryImage, GalleryAlbum
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 _serialize_image(i: "GalleryImage") -> dict:
return {
"id": i.id,
"filename": i.filename,
"prompt": (i.prompt or "")[:200],
"model": i.model or "",
"size": i.size or "",
"tags": i.tags or "",
"favorite": bool(i.favorite),
"album_id": i.album_id or "",
"session_id": i.session_id or "",
"width": i.width,
"height": i.height,
"file_size": i.file_size,
"taken_at": i.taken_at.isoformat() if i.taken_at else "",
"camera_make": i.camera_make or "",
"camera_model": i.camera_model or "",
"created_at": i.created_at.isoformat() if i.created_at else "",
}
def cmd_list(args):
db = SessionLocal()
try:
q = db.query(GalleryImage).filter(GalleryImage.is_active == True) # noqa: E712
if args.favorites:
q = q.filter(GalleryImage.favorite == True) # noqa: E712
if args.album:
al = db.query(GalleryAlbum).filter(GalleryAlbum.name == args.album).first()
if not al:
fail(f"no album named {args.album!r}")
q = q.filter(GalleryImage.album_id == al.id)
if args.tag:
q = q.filter(GalleryImage.tags.ilike(f"%{args.tag}%"))
q = q.order_by(GalleryImage.created_at.desc()).limit(args.limit)
emit([_serialize_image(i) for i in q.all()], args)
finally:
db.close()
def cmd_show(args):
db = SessionLocal()
try:
i = db.get(GalleryImage, args.id)
if not i:
fail(f"no image with id {args.id!r}")
out = _serialize_image(i)
out["prompt_full"] = i.prompt or ""
out["ai_tags"] = i.ai_tags or ""
out["gps_lat"] = i.gps_lat or ""
out["gps_lng"] = i.gps_lng or ""
out["file_hash"] = i.file_hash or ""
emit(out, args)
finally:
db.close()
def cmd_albums(args):
db = SessionLocal()
try:
rows = db.query(GalleryAlbum).order_by(GalleryAlbum.name.asc()).all()
emit([
{"id": a.id, "name": a.name, "image_count": len(a.images)}
for a in rows
], args)
finally:
db.close()
def cmd_search(args):
db = SessionLocal()
try:
like = f"%{args.query}%"
rows = db.query(GalleryImage).filter(
GalleryImage.is_active == True, # noqa: E712
).filter(
(GalleryImage.prompt.ilike(like)) |
(GalleryImage.tags.ilike(like)) |
(GalleryImage.ai_tags.ilike(like))
).order_by(GalleryImage.created_at.desc()).limit(args.limit).all()
emit([_serialize_image(i) for i in rows], args)
finally:
db.close()
def cmd_delete(args):
db = SessionLocal()
try:
i = db.get(GalleryImage, args.id)
if not i:
fail(f"no image with id {args.id!r}")
i.is_active = False
db.commit()
emit({"ok": True, "id": i.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-gallery", parents=[common])
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", parents=[common])
pl.add_argument("--limit", type=int, default=50)
pl.add_argument("--favorites", action="store_true")
pl.add_argument("--album", help="filter by album name")
pl.add_argument("--tag", help="substring match against tags column")
pl.set_defaults(func=cmd_list)
psh = sub.add_parser("show", parents=[common])
psh.add_argument("id")
psh.set_defaults(func=cmd_show)
pa = sub.add_parser("albums", parents=[common])
pa.set_defaults(func=cmd_albums)
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()))

145
scripts/odysseus-logs Executable file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""odysseus-logs — unified view of log files across the app.
Right now logs live in two places:
- logs/ app-level (compound.log, future runtime)
- /tmp/odysseus-tmux/*.log per-tmux-session model download/serve logs
This CLI surfaces them so you don't have to remember which directory
holds what.
odysseus-logs list # every log we know about
odysseus-logs tail NAME # tail -f a specific log
odysseus-logs tail NAME --lines 200 # last N lines only (no follow)
odysseus-logs cat NAME # print full content
odysseus-logs clean # delete tmux logs older than 7 days
"""
from __future__ import annotations
import os
import 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, subprocess, time
from datetime import datetime
from pathlib import Path
_APP_LOGS = _REPO_ROOT / "logs"
_TMUX_LOGS = Path("/tmp/odysseus-tmux")
def _enumerate() -> list[dict]:
"""Return every known log file as {name, path, bytes, modified}."""
out = []
for base in (_APP_LOGS, _TMUX_LOGS):
if not base.is_dir():
continue
for p in sorted(base.glob("*.log")):
try:
st = p.stat()
except OSError:
continue
out.append({
"name": p.name,
"path": str(p),
"bytes": st.st_size,
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(),
})
out.sort(key=lambda r: r["modified"], reverse=True)
return out
def _resolve(name: str) -> Path | None:
"""Match a log by exact filename, basename-without-extension, or
substring. Returns the most-recently-modified match if there are
ties."""
candidates = []
for base in (_APP_LOGS, _TMUX_LOGS):
if not base.is_dir():
continue
for p in base.glob("*.log"):
if p.name == name or p.stem == name or name in p.name:
candidates.append(p)
if not candidates:
return None
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return candidates[0]
def cmd_list(args):
emit(_enumerate(), args)
def cmd_tail(args):
p = _resolve(args.name)
if p is None:
fail(f"no log matching {args.name!r}; run `odysseus-logs list`")
if args.follow:
# Exec tail -f so signals + buffering are native.
os.execvp("tail", ["tail", "-n", str(args.lines), "-f", str(p)])
# Non-follow: print last N lines + exit.
proc = subprocess.run(["tail", "-n", str(args.lines), str(p)],
capture_output=True, text=True)
sys.stdout.write(proc.stdout)
sys.exit(proc.returncode)
def cmd_cat(args):
p = _resolve(args.name)
if p is None:
fail(f"no log matching {args.name!r}")
sys.stdout.write(p.read_text(errors="replace"))
def cmd_clean(args):
"""Delete tmux logs older than --days. Doesn't touch app logs."""
if not _TMUX_LOGS.is_dir():
emit({"deleted": 0, "kept": 0}, args)
return
cutoff = time.time() - args.days * 86400
deleted, kept = [], 0
for p in _TMUX_LOGS.glob("*.log"):
try:
if p.stat().st_mtime < cutoff:
p.unlink()
deleted.append(p.name)
else:
kept += 1
except OSError:
continue
emit({"deleted": deleted, "kept": kept, "cutoff_days": args.days}, args)
def _build_parser():
p = common_parser("odysseus-logs", "Tail / list logs across the app.")
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", parents=p._common_parents)
pl.set_defaults(func=cmd_list)
pt = sub.add_parser("tail", parents=p._common_parents)
pt.add_argument("name")
pt.add_argument("-n", "--lines", type=int, default=80)
pt.add_argument("-f", "--follow", action="store_true",
help="follow new lines (tail -f); omit for one-shot")
pt.set_defaults(func=cmd_tail)
pc = sub.add_parser("cat", parents=p._common_parents)
pc.add_argument("name")
pc.set_defaults(func=cmd_cat)
pcl = sub.add_parser("clean", parents=p._common_parents)
pcl.add_argument("--days", type=int, default=7,
help="delete tmux logs older than N days (default 7)")
pcl.set_defaults(func=cmd_clean)
return p
if __name__ == "__main__":
sys.exit(run(_build_parser()))

395
scripts/odysseus-mail Executable file
View File

@@ -0,0 +1,395 @@
#!/usr/bin/env python3
"""odysseus-mail — Unix-style command-line wrapper around the email
backend that powers the web UI.
Calls the same helpers `routes/email_helpers.py` exports, so a request
issued from the shell hits IMAP/SMTP through the same connection pool
and the same parsing pipeline as the HTTP routes. State is shared via
`data/app.db` and `data/.app_key` (passwords decrypt automatically).
Output is JSON on stdout, errors on stderr, non-zero exit on failure.
Designed to compose:
odysseus-mail list --folder INBOX --limit 5 \\
| jq -r '.[] | .uid + "\\t" + .from_name + "\\t" + .subject'
odysseus-mail send --to alice@example.com --subject hi <<<"hello"
odysseus-mail folders --account work | jq
Subcommands:
list List recent messages in a folder
read Fetch one message by UID
folders Enumerate IMAP folders on the account
accounts List configured email accounts
send Send a message via SMTP (body from stdin)
Run with a subcommand + --help for argument details.
"""
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
import email as email_mod
import json
import logging
import os
import re
import sys
from datetime import datetime
from pathlib import Path
# Anchor the path so the script works when invoked from anywhere.
# The web-app logger is verbose at import time (cot_prompts, embeddings,
# etc.). For a CLI tool we only care about warnings+; users can re-raise
# with `LOG_LEVEL=DEBUG odysseus-mail ...`. We force the root level AFTER
# imports because some submodules call basicConfig themselves during
# initialization, which we have to override.
def quiet_logs() -> None:
level_name = os.environ.get("LOG_LEVEL", "WARNING").upper()
level = getattr(logging, level_name, logging.WARNING)
root = logging.getLogger()
root.setLevel(level)
for handler in root.handlers:
handler.setLevel(level)
# Suppress now so import-time `.info()` calls in helpers stay quiet.
quiet_logs()
# Defer the heavy imports until after path setup so a friendlier error
# message can be printed if the venv isn't activated.
try:
from routes.email_helpers import (
_imap, _get_email_config, _decode_header,
_extract_text, _extract_html,
_list_attachments_from_msg,
)
from routes.email_pollers import (
_scheduled_poll_once, _run_auto_summarize_once,
)
from core.database import SessionLocal, EmailAccount
# Re-apply: some submodules call basicConfig during their own
# import, which raises root level back to INFO. Quench again.
quiet_logs()
except ModuleNotFoundError as e:
sys.stderr.write(
f"error: {e}\n"
f"hint: run from the repo root with the venv active:\n"
f" source venv/bin/activate && {sys.argv[0]} --help\n"
)
sys.exit(2)
def emit(obj, args) -> None:
"""Emit a JSON value on stdout. Pretty-print if requested or if
stdout is a TTY."""
pretty = getattr(args, "pretty", False) or sys.stdout.isatty()
indent = 2 if pretty else None
json.dump(obj, sys.stdout, indent=indent, default=str, ensure_ascii=False)
sys.stdout.write("\n")
def fail(msg: str, code: int = 1) -> "None":
sys.stderr.write(f"error: {msg}\n")
sys.exit(code)
def _q(name: str) -> str:
"""Local copy of the IMAP mailbox quoter (matches email_helpers._q)."""
return '"' + (name or "").replace("\\", "\\\\").replace('"', '\\"') + '"'
# ─── list ────────────────────────────────────────────────────────────
def cmd_list(args) -> None:
"""List recent messages in a folder. Output: array of {uid, date,
from_addr, from_name, subject, is_read, is_answered}."""
with _imap(args.account) as conn:
st, _ = conn.select(_q(args.folder), readonly=True)
if st != "OK":
fail(f"select {args.folder!r} failed: {st}")
st, data = conn.search(None, "ALL")
if st != "OK" or not data[0]:
emit([], args)
return
all_uids = data[0].split()
# Newest first, limit
uids = list(reversed(all_uids))[: args.limit]
out = []
for uid in uids:
try:
st, msg_data = conn.fetch(uid, "(FLAGS RFC822.HEADER)")
if st != "OK":
continue
raw_header = None
flags = ""
for part in msg_data:
if isinstance(part, tuple):
meta = part[0].decode() if isinstance(part[0], bytes) else str(part[0])
if isinstance(part[0], bytes) and b"RFC822.HEADER" in part[0]:
raw_header = part[1]
elif "RFC822.HEADER" in meta:
raw_header = part[1]
m = re.search(r"FLAGS \(([^)]*)\)", meta)
if m:
flags = m.group(1)
if not raw_header:
continue
msg = email_mod.message_from_bytes(raw_header)
subject = _decode_header(msg.get("Subject", "(no subject)"))
sender = _decode_header(msg.get("From", "unknown"))
from email.utils import parseaddr, parsedate_to_datetime
sender_name, sender_addr = parseaddr(sender)
date_raw = msg.get("Date", "")
try:
iso = parsedate_to_datetime(date_raw).isoformat() if date_raw else ""
except Exception:
iso = ""
out.append({
"uid": uid.decode(),
"date": iso,
"from_addr": sender_addr,
"from_name": sender_name or sender_addr,
"subject": subject,
"is_read": "\\Seen" in flags,
"is_answered": "\\Answered" in flags,
})
except Exception as e:
sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n")
emit(out, args)
# ─── read ────────────────────────────────────────────────────────────
def cmd_read(args) -> None:
"""Fetch one message. Output: {uid, headers, body_text, body_html?,
attachments}."""
with _imap(args.account) as conn:
st, _ = conn.select(_q(args.folder), readonly=True)
if st != "OK":
fail(f"select {args.folder!r} failed: {st}")
st, msg_data = conn.fetch(args.uid.encode(), "(BODY.PEEK[])")
if st != "OK":
fail(f"fetch UID {args.uid} failed: {st}")
raw = msg_data[0][1]
msg = email_mod.message_from_bytes(raw)
headers = {
"from": _decode_header(msg.get("From", "")),
"to": _decode_header(msg.get("To", "")),
"cc": _decode_header(msg.get("Cc", "")),
"subject": _decode_header(msg.get("Subject", "")),
"date": msg.get("Date", ""),
"message_id": msg.get("Message-ID", ""),
}
out = {
"uid": args.uid,
"headers": headers,
"body_text": _extract_text(msg) or "",
"attachments": _list_attachments_from_msg(msg),
}
if args.html:
out["body_html"] = _extract_html(msg) or ""
emit(out, args)
# ─── folders ─────────────────────────────────────────────────────────
def cmd_folders(args) -> None:
"""List IMAP folders on the account. Output: array of folder names."""
with _imap(args.account) as conn:
st, folders = conn.list()
if st != "OK":
fail("LIST failed")
names = []
for f in folders or []:
decoded = f.decode() if isinstance(f, bytes) else str(f)
m = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded)
if m:
names.append(m.group(1) or m.group(2))
emit(names, args)
# ─── accounts ────────────────────────────────────────────────────────
def cmd_accounts(args) -> None:
"""List configured email accounts (passwords masked). Output: array
of {id, name, is_default, enabled, imap_host, smtp_host, from_address}."""
db = SessionLocal()
try:
rows = db.query(EmailAccount).order_by(
EmailAccount.is_default.desc(),
EmailAccount.created_at.asc(),
).all()
out = [{
"id": r.id,
"name": r.name,
"is_default": bool(r.is_default),
"enabled": bool(r.enabled),
"imap_host": r.imap_host or "",
"imap_user": r.imap_user or "",
"smtp_host": r.smtp_host or "",
"smtp_user": r.smtp_user or "",
"from_address": r.from_address or "",
"has_password": bool(r.imap_password) or bool(r.smtp_password),
} for r in rows]
emit(out, args)
finally:
db.close()
# ─── poll-scheduled ──────────────────────────────────────────────────
def cmd_poll_scheduled(args) -> None:
"""One pass of the scheduled-email queue. Cron-friendly: idempotent,
exits 0 on success even if zero rows were due. Output: {sent: [ids],
failed: [{id, error}]}."""
result = _scheduled_poll_once()
emit(result, args)
if result.get("failed"):
sys.exit(1)
# ─── poll-summary ────────────────────────────────────────────────────
def cmd_poll_summary(args) -> None:
"""One pass of the auto-summarize / auto-reply pipeline over recent
mail. Cron-friendly: a single shot you can wire to a systemd timer
instead of running the FastAPI process all the time."""
import asyncio
msg = asyncio.run(_run_auto_summarize_once(
do_summary=args.summary,
do_reply=args.reply,
do_tag=args.tag,
do_spam=args.spam,
days_back=args.days,
))
emit({"message": msg or "(no output)"}, args)
# ─── send ────────────────────────────────────────────────────────────
def cmd_send(args) -> None:
"""Send a message via SMTP. Body is read from stdin."""
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
body = sys.stdin.read()
if not body and not args.allow_empty:
fail("body is empty (pipe content into stdin, or pass --allow-empty)")
cfg = _get_email_config(args.account)
if not (cfg.get("smtp_host") and cfg.get("smtp_user") and cfg.get("smtp_password")):
fail(
f"SMTP not configured for account {cfg.get('account_name', '<default>')!r}; "
f"check `odysseus-mail accounts` and the web settings"
)
outer = MIMEMultipart("alternative")
outer["From"] = cfg["from_address"]
outer["To"] = args.to
if args.cc:
outer["Cc"] = args.cc
outer["Subject"] = args.subject
outer["Date"] = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
outer.attach(MIMEText(body, "plain", "utf-8"))
recipients = [r.strip() for r in args.to.split(",") if r.strip()]
if args.cc:
recipients.extend([r.strip() for r in args.cc.split(",") if r.strip()])
if args.bcc:
recipients.extend([r.strip() for r in args.bcc.split(",") if r.strip()])
if args.dry_run:
emit({
"dry_run": True,
"from": cfg["from_address"],
"recipients": recipients,
"subject": args.subject,
"bytes": len(outer.as_bytes()),
}, args)
return
with smtplib.SMTP_SSL(cfg["smtp_host"], int(cfg["smtp_port"] or 465)) as smtp:
smtp.login(cfg["smtp_user"], cfg["smtp_password"])
smtp.sendmail(cfg["from_address"], recipients, outer.as_string())
emit({"ok": True, "from": cfg["from_address"], "recipients": recipients}, args)
# ─── argparse wiring ─────────────────────────────────────────────────
def _build_parser() -> argparse.ArgumentParser:
# Common flags shared by every subcommand. Using `parents=[common]`
# lets `--account` / `--pretty` appear either before OR after the
# subcommand name, which matches how most Unix CLIs work.
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--account", help="Account ID (default: configured default)")
common.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
p = argparse.ArgumentParser(
prog="odysseus-mail",
description="Shell-friendly wrapper around the Odysseus email backend.",
parents=[common],
)
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", help="list recent messages in a folder", parents=[common])
pl.add_argument("--folder", default="INBOX")
pl.add_argument("--limit", type=int, default=20)
pl.set_defaults(func=cmd_list)
pr = sub.add_parser("read", help="fetch one message by UID", parents=[common])
pr.add_argument("uid")
pr.add_argument("--folder", default="INBOX")
pr.add_argument("--html", action="store_true", help="include HTML body in output")
pr.set_defaults(func=cmd_read)
pf = sub.add_parser("folders", help="list IMAP folders", parents=[common])
pf.set_defaults(func=cmd_folders)
pa = sub.add_parser("accounts", help="list configured email accounts", parents=[common])
pa.set_defaults(func=cmd_accounts)
ps = sub.add_parser("send", help="send a message (body from stdin)", parents=[common])
ps.add_argument("--to", required=True)
ps.add_argument("--subject", required=True)
ps.add_argument("--cc", default="")
ps.add_argument("--bcc", default="")
ps.add_argument("--dry-run", action="store_true", help="don't actually send; print envelope")
ps.add_argument("--allow-empty", action="store_true", help="permit empty body")
ps.set_defaults(func=cmd_send)
pps = sub.add_parser(
"poll-scheduled",
help="one pass of the scheduled-email queue (cron/systemd-friendly)",
parents=[common],
)
pps.set_defaults(func=cmd_poll_scheduled)
psm = sub.add_parser(
"poll-summary",
help="one pass of the AI auto-summarize / auto-reply pipeline",
parents=[common],
)
psm.add_argument("--summary", action="store_true", default=True, help="run summarize step (default on)")
psm.add_argument("--no-summary", dest="summary", action="store_false")
psm.add_argument("--reply", action="store_true", default=True, help="run auto-reply step (default on)")
psm.add_argument("--no-reply", dest="reply", action="store_false")
psm.add_argument("--tag", action="store_true", default=False, help="also run AI tagging step")
psm.add_argument("--spam", action="store_true", default=False, help="also run AI spam-classify step")
psm.add_argument("--days", type=int, default=1, help="how far back to scan (default 1)")
psm.set_defaults(func=cmd_poll_summary)
return p
if __name__ == "__main__":
sys.exit(run(_build_parser()))

196
scripts/odysseus-mcp Executable file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""odysseus-mcp — shell wrapper for MCP (Model Context Protocol) servers.
MCP servers are configured in the `mcp_servers` table. The web app's
McpManager handles live connections; this CLI manages the *config*
(read, add, enable/disable, delete). Runtime connection state lives
in the manager process — query it via the web UI or `curl /api/mcp/servers`.
odysseus-mcp list # configured servers
odysseus-mcp show SERVER_ID
odysseus-mcp enable SERVER_ID
odysseus-mcp disable SERVER_ID
odysseus-mcp add --name "Filesystem" --transport stdio \\
--command npx --args '["@modelcontextprotocol/server-filesystem","/tmp"]'
odysseus-mcp delete SERVER_ID
"""
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, uuid
from pathlib import Path
try:
from core.database import SessionLocal, McpServer
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 _serialize(s: "McpServer", redact_env: bool = True) -> dict:
try:
args_arr = json.loads(s.args) if s.args else []
except json.JSONDecodeError:
args_arr = []
try:
env_obj = json.loads(s.env) if s.env else {}
except json.JSONDecodeError:
env_obj = {}
if redact_env and env_obj:
env_obj = {k: ("***" if v else "") for k, v in env_obj.items()}
return {
"id": s.id,
"name": s.name,
"transport": s.transport,
"command": s.command or "",
"args": args_arr,
"env": env_obj,
"url": s.url or "",
"is_enabled": bool(s.is_enabled),
"has_oauth": bool(s.oauth_config),
"created_at": s.created_at.isoformat() if s.created_at else "",
}
def cmd_list(args):
db = SessionLocal()
try:
rows = db.query(McpServer).order_by(McpServer.name.asc()).all()
emit([_serialize(s) for s in rows], args)
finally:
db.close()
def cmd_show(args):
db = SessionLocal()
try:
s = db.get(McpServer, args.id)
if not s:
fail(f"no MCP server with id {args.id!r}")
# show shows env keys; values still redacted unless --reveal
out = _serialize(s, redact_env=not args.reveal)
emit(out, args)
finally:
db.close()
def cmd_enable(args):
_set_enabled(args.id, True, args)
def cmd_disable(args):
_set_enabled(args.id, False, args)
def _set_enabled(server_id: str, enabled: bool, args):
db = SessionLocal()
try:
s = db.get(McpServer, server_id)
if not s:
fail(f"no MCP server with id {server_id!r}")
s.is_enabled = enabled
db.commit()
emit({"ok": True, "id": s.id, "is_enabled": s.is_enabled}, args)
finally:
db.close()
def cmd_add(args):
if args.transport == "stdio" and not args.command:
fail("--command is required for stdio transport")
if args.transport == "sse" and not args.url:
fail("--url is required for sse transport")
try:
args_arr = json.loads(args.args) if args.args else []
if not isinstance(args_arr, list):
raise ValueError("--args must be a JSON array")
except (json.JSONDecodeError, ValueError) as e:
fail(f"invalid --args: {e}")
try:
env_obj = json.loads(args.env) if args.env else {}
if not isinstance(env_obj, dict):
raise ValueError("--env must be a JSON object")
except (json.JSONDecodeError, ValueError) as e:
fail(f"invalid --env: {e}")
db = SessionLocal()
try:
s = McpServer(
id=str(uuid.uuid4()),
name=args.name,
transport=args.transport,
command=args.command or None,
args=json.dumps(args_arr) if args_arr else None,
env=json.dumps(env_obj) if env_obj else None,
url=args.url or None,
is_enabled=not args.disabled,
)
db.add(s)
db.commit()
db.refresh(s)
emit(_serialize(s), args)
finally:
db.close()
def cmd_delete(args):
db = SessionLocal()
try:
s = db.get(McpServer, args.id)
if not s:
fail(f"no MCP server with id {args.id!r}")
snap = _serialize(s)
db.delete(s)
db.commit()
emit({"ok": True, "deleted": snap}, 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-mcp", parents=[common])
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", parents=[common])
pl.set_defaults(func=cmd_list)
psh = sub.add_parser("show", parents=[common])
psh.add_argument("id")
psh.add_argument("--reveal", action="store_true", help="show env values unredacted")
psh.set_defaults(func=cmd_show)
pe = sub.add_parser("enable", parents=[common])
pe.add_argument("id")
pe.set_defaults(func=cmd_enable)
pdis = sub.add_parser("disable", parents=[common])
pdis.add_argument("id")
pdis.set_defaults(func=cmd_disable)
pa = sub.add_parser("add", parents=[common])
pa.add_argument("--name", required=True)
pa.add_argument("--transport", choices=["stdio", "sse"], default="stdio")
pa.add_argument("--command", help="stdio: executable to run")
pa.add_argument("--args", help="JSON array of args, e.g. '[\"--root\",\"/tmp\"]'")
pa.add_argument("--env", help="JSON object of env vars, e.g. '{\"KEY\":\"value\"}'")
pa.add_argument("--url", help="sse: server URL")
pa.add_argument("--disabled", action="store_true", help="create disabled")
pa.set_defaults(func=cmd_add)
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()))

153
scripts/odysseus-memory Executable file
View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""odysseus-memory — shell wrapper for the AI memory store.
The chat assistant accumulates durable facts about the user (name,
projects, preferences, contacts) into `data/memory.json`. This CLI
lets you inspect, search, and prune them from the shell.
odysseus-memory list # all entries (paged)
odysseus-memory list --category preference
odysseus-memory search "tokyo" # substring match
odysseus-memory show MEMORY_ID
odysseus-memory add "User lives in Tokyo" --category fact
odysseus-memory delete MEMORY_ID
odysseus-memory categories # counts per category
Reads/writes the same `data/memory.json` the web UI uses via the
`MemoryManager` helper. Atomic — the manager handles the temp-file
swap so partial writes can't corrupt the file.
"""
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 services.memory.memory import MemoryManager
quiet_logs()
except ModuleNotFoundError as e:
sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n")
sys.exit(2)
_DATA_DIR = str(_REPO_ROOT / "data")
_mgr: MemoryManager | None = None
def _manager() -> MemoryManager:
global _mgr
if _mgr is None:
_mgr = MemoryManager(_DATA_DIR)
return _mgr
def cmd_list(args):
entries = _manager().load_all()
if args.category:
entries = [e for e in entries if (e.get("category") or "fact") == args.category]
if args.source:
entries = [e for e in entries if (e.get("source") or "user") == args.source]
if args.owner:
entries = [e for e in entries if (e.get("owner") or "") == args.owner]
# Newest first
entries = sorted(entries, key=lambda e: e.get("timestamp", 0), reverse=True)
emit(entries[: args.limit], args)
def cmd_search(args):
q = args.query.lower()
entries = _manager().load_all()
matches = [e for e in entries if q in (e.get("text") or "").lower()]
matches = sorted(matches, key=lambda e: e.get("timestamp", 0), reverse=True)
emit(matches[: args.limit], args)
def cmd_show(args):
for e in _manager().load_all():
if e.get("id") == args.id:
emit(e, args)
return
fail(f"no memory with id {args.id!r}")
def cmd_add(args):
entry = _manager().add_entry(
args.text,
source="cli",
category=args.category,
owner=args.owner,
)
# add_entry doesn't save by default — the call in chat does it
# after dedup checks. Persist here so a one-shot CLI add sticks.
all_entries = _manager().load_all()
if not any(e.get("id") == entry.get("id") for e in all_entries):
all_entries.append(entry)
_manager().save(all_entries)
emit(entry, args)
def cmd_delete(args):
entries = _manager().load_all()
target = next((e for e in entries if e.get("id") == args.id), None)
if not target:
fail(f"no memory with id {args.id!r}")
remaining = [e for e in entries if e.get("id") != args.id]
_manager().save(remaining)
emit({"ok": True, "deleted": target}, args)
def cmd_categories(args):
counts: dict[str, int] = {}
for e in _manager().load_all():
cat = e.get("category") or "fact"
counts[cat] = counts.get(cat, 0) + 1
rows = sorted(counts.items(), key=lambda kv: -kv[1])
emit([{"category": c, "count": n} for c, n in rows], args)
def _build_parser():
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--pretty", action="store_true")
p = argparse.ArgumentParser(prog="odysseus-memory", parents=[common])
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", parents=[common])
pl.add_argument("--category")
pl.add_argument("--source")
pl.add_argument("--owner")
pl.add_argument("--limit", type=int, default=50)
pl.set_defaults(func=cmd_list)
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)
psh = sub.add_parser("show", parents=[common])
psh.add_argument("id")
psh.set_defaults(func=cmd_show)
pa = sub.add_parser("add", parents=[common])
pa.add_argument("text")
pa.add_argument("--category", default="fact")
pa.add_argument("--owner")
pa.set_defaults(func=cmd_add)
pd = sub.add_parser("delete", parents=[common])
pd.add_argument("id")
pd.set_defaults(func=cmd_delete)
pc = sub.add_parser("categories", parents=[common])
pc.set_defaults(func=cmd_categories)
return p
if __name__ == "__main__":
sys.exit(run(_build_parser()))

164
scripts/odysseus-notes Executable file
View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""odysseus-notes — shell wrapper around the notes feature.
Reads the same SQLite the web UI uses. Output JSON; pipe-friendly.
odysseus-notes list --label calendar | jq -r '.[] | .title'
odysseus-notes show NOTE_ID
odysseus-notes search "invoice"
odysseus-notes create --title "Buy milk" --content "..."
odysseus-notes delete NOTE_ID
"""
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, uuid
from datetime import datetime
from pathlib import Path
try:
from core.database import SessionLocal, Note
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 _serialize(n: "Note") -> dict:
return {
"id": n.id,
"title": n.title or "",
"content": n.content or "",
"items": json.loads(n.items) if n.items else [],
"note_type": n.note_type or "note",
"color": n.color or "",
"label": n.label or "",
"pinned": bool(n.pinned),
"archived": bool(n.archived),
"due_date": n.due_date or "",
"source": n.source or "user",
"created_at": n.created_at.isoformat() if n.created_at else "",
"updated_at": n.updated_at.isoformat() if n.updated_at else "",
}
def cmd_list(args):
db = SessionLocal()
try:
q = db.query(Note)
if not args.archived:
q = q.filter(Note.archived == False) # noqa: E712
if args.label:
q = q.filter(Note.label == args.label)
if args.pinned:
q = q.filter(Note.pinned == True) # noqa: E712
q = q.order_by(Note.pinned.desc(), Note.sort_order.asc(), Note.updated_at.desc()).limit(args.limit)
emit([_serialize(n) for n in q.all()], args)
finally:
db.close()
def cmd_show(args):
db = SessionLocal()
try:
n = db.get(Note, args.id)
if not n:
fail(f"no note with id {args.id!r}")
emit(_serialize(n), args)
finally:
db.close()
def cmd_search(args):
db = SessionLocal()
try:
like = f"%{args.query}%"
rows = db.query(Note).filter(
(Note.title.ilike(like)) | (Note.content.ilike(like))
).order_by(Note.updated_at.desc()).limit(args.limit).all()
emit([_serialize(n) for n in rows], args)
finally:
db.close()
def cmd_create(args):
db = SessionLocal()
try:
n = Note(
id=str(uuid.uuid4()),
title=args.title,
content=args.content or "",
note_type=args.type,
color=args.color or None,
label=args.label or None,
pinned=bool(args.pin),
source="user",
)
db.add(n)
db.commit()
db.refresh(n)
emit(_serialize(n), args)
finally:
db.close()
def cmd_delete(args):
db = SessionLocal()
try:
n = db.get(Note, args.id)
if not n:
fail(f"no note with id {args.id!r}")
snap = _serialize(n)
db.delete(n)
db.commit()
emit({"ok": True, "deleted": snap}, 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-notes", parents=[common])
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", parents=[common])
pl.add_argument("--label")
pl.add_argument("--archived", action="store_true", help="include archived")
pl.add_argument("--pinned", action="store_true", help="pinned only")
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)
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)
pc = sub.add_parser("create", parents=[common])
pc.add_argument("--title", required=True)
pc.add_argument("--content", default="")
pc.add_argument("--type", choices=["note", "checklist"], default="note")
pc.add_argument("--color")
pc.add_argument("--label")
pc.add_argument("--pin", action="store_true")
pc.set_defaults(func=cmd_create)
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()))

123
scripts/odysseus-personal Executable file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""odysseus-personal — shell wrapper for the personal-docs RAG index.
`data/personal_docs/` holds local files indexed into RAG so chat can
recall their content (`contacts.txt`, runbook fragments, etc.). This
CLI lists indexed directories + manages the index.
odysseus-personal list # files currently in the index
odysseus-personal dirs # tracked directory roots
odysseus-personal add-dir DIR
odysseus-personal remove-dir DIR
odysseus-personal reload # re-scan all dirs
odysseus-personal exclude PATH # mark a file as excluded
"""
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 src.personal_docs import PersonalDocsManager
quiet_logs()
except ModuleNotFoundError as e:
sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n")
sys.exit(2)
_DATA_DIR = str(_REPO_ROOT / "data")
_mgr: PersonalDocsManager | None = None
def _manager() -> PersonalDocsManager:
global _mgr
if _mgr is None:
_mgr = PersonalDocsManager(_DATA_DIR)
return _mgr
def cmd_list(args):
files = getattr(_manager(), "index", []) or []
out = [
{"name": f.get("name"), "size": f.get("size"), "path": f.get("path", "")}
for f in files
]
emit(out[: args.limit], args)
def cmd_dirs(args):
fn = getattr(_manager(), "get_indexed_directories", None)
if fn is None:
emit([], args)
return
emit(fn() or [], args)
def cmd_add_dir(args):
fn = getattr(_manager(), "add_directory", None)
if fn is None:
fail("PersonalDocsManager has no add_directory method on this version")
fn(args.directory)
emit({"ok": True, "directory": args.directory}, args)
def cmd_remove_dir(args):
fn = getattr(_manager(), "remove_directory", None)
if fn is None:
fail("PersonalDocsManager has no remove_directory method on this version")
fn(args.directory)
emit({"ok": True, "directory": args.directory}, args)
def cmd_reload(args):
_manager().refresh_index()
emit({"ok": True, "count": len(_manager().index)}, args)
def cmd_exclude(args):
fn = getattr(_manager(), "exclude_file", None)
if fn is None:
fail("PersonalDocsManager has no exclude_file method on this version")
fn(args.path)
emit({"ok": True, "excluded": args.path}, args)
def _build_parser():
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--pretty", action="store_true")
p = argparse.ArgumentParser(prog="odysseus-personal", parents=[common])
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", parents=[common])
pl.add_argument("--limit", type=int, default=500)
pl.set_defaults(func=cmd_list)
pd = sub.add_parser("dirs", parents=[common])
pd.set_defaults(func=cmd_dirs)
pa = sub.add_parser("add-dir", parents=[common])
pa.add_argument("directory")
pa.set_defaults(func=cmd_add_dir)
pr = sub.add_parser("remove-dir", parents=[common])
pr.add_argument("directory")
pr.set_defaults(func=cmd_remove_dir)
prl = sub.add_parser("reload", parents=[common])
prl.set_defaults(func=cmd_reload)
pex = sub.add_parser("exclude", parents=[common])
pex.add_argument("path")
pex.set_defaults(func=cmd_exclude)
return p
if __name__ == "__main__":
sys.exit(run(_build_parser()))

129
scripts/odysseus-preset Executable file
View File

@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""odysseus-preset — shell wrapper for AI system-prompt presets.
Presets are name → {name, temperature, system_prompt} in
`data/presets.json`. Used by the chat UI's preset picker.
odysseus-preset list
odysseus-preset get NAME
odysseus-preset set NAME --temperature 0.7 --prompt "You are..."
odysseus-preset set NAME --prompt-file ./prompt.md
odysseus-preset delete NAME
"""
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
_PATH = _REPO_ROOT / "data" / "presets.json"
def _load() -> dict:
if not _PATH.exists():
return {}
try:
return json.loads(_PATH.read_text())
except json.JSONDecodeError as e:
fail(f"presets.json corrupt: {e}")
def _save(data: dict) -> None:
_PATH.parent.mkdir(parents=True, exist_ok=True)
# Backup; same pattern as cookbook state-set.
if _PATH.exists():
try:
_PATH.with_suffix(_PATH.suffix + ".bak").write_bytes(_PATH.read_bytes())
except Exception:
pass
tmp = _PATH.with_suffix(_PATH.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False))
tmp.replace(_PATH)
def cmd_list(args):
presets = _load()
rows = []
for key, val in sorted(presets.items()):
if not isinstance(val, dict):
continue
rows.append({
"id": key,
"name": val.get("name") or key,
"temperature": val.get("temperature"),
"prompt_length": len(val.get("system_prompt") or ""),
})
emit(rows, args)
def cmd_get(args):
presets = _load()
if args.name not in presets:
fail(f"no preset named {args.name!r}")
emit({"id": args.name, **presets[args.name]}, args)
def cmd_set(args):
prompt = args.prompt
if args.prompt_file:
prompt = Path(args.prompt_file).read_text()
if prompt is None and args.temperature is None:
fail("nothing to set — pass --prompt, --prompt-file, or --temperature")
presets = _load()
entry = dict(presets.get(args.name) or {})
entry.setdefault("name", args.name)
if prompt is not None:
entry["system_prompt"] = prompt
if args.temperature is not None:
entry["temperature"] = args.temperature
if args.display_name:
entry["name"] = args.display_name
presets[args.name] = entry
_save(presets)
emit({"ok": True, "id": args.name, "entry": entry}, args)
def cmd_delete(args):
presets = _load()
if args.name not in presets:
fail(f"no preset named {args.name!r}")
snap = presets.pop(args.name)
_save(presets)
emit({"ok": True, "deleted": {"id": args.name, **snap}}, args)
def _build_parser():
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--pretty", action="store_true")
p = argparse.ArgumentParser(prog="odysseus-preset", parents=[common])
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", parents=[common])
pl.set_defaults(func=cmd_list)
pg = sub.add_parser("get", parents=[common])
pg.add_argument("name")
pg.set_defaults(func=cmd_get)
ps = sub.add_parser("set", parents=[common])
ps.add_argument("name")
ps.add_argument("--prompt", help="literal system prompt text")
ps.add_argument("--prompt-file", help="path to read prompt from")
ps.add_argument("--temperature", type=float)
ps.add_argument("--display-name")
ps.set_defaults(func=cmd_set)
pd = sub.add_parser("delete", parents=[common])
pd.add_argument("name")
pd.set_defaults(func=cmd_delete)
return p
if __name__ == "__main__":
sys.exit(run(_build_parser()))

160
scripts/odysseus-research Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""odysseus-research — shell wrapper for deep-research sessions.
Each research run is a JSON blob in `data/deep_research/<id>.json`
holding the query, findings, sources, and final report. This CLI
enumerates and inspects them — running new research requires the
streaming endpoint, so it's not exposed here.
odysseus-research list [--limit N] [--status complete|running|cancelled]
odysseus-research show RP_ID # full record (large)
odysseus-research report RP_ID --raw # just the markdown report
odysseus-research search "query text"
odysseus-research delete RP_ID
"""
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
_DATA_DIR = _REPO_ROOT / "data" / "deep_research"
def _load(rp_id: str) -> dict | None:
path = _DATA_DIR / f"{rp_id}.json"
if not path.exists():
return None
try:
return json.loads(path.read_text())
except json.JSONDecodeError:
return None
def _summarize(rp_id: str, data: dict) -> dict:
return {
"id": rp_id,
"query": (data.get("query") or "")[:200],
"category": data.get("category") or "",
"status": data.get("status") or "",
"started_at": data.get("started_at") or "",
"completed_at": data.get("completed_at") or "",
"sources": len(data.get("sources") or []),
"stats": data.get("stats") or {},
}
def cmd_list(args):
if not _DATA_DIR.is_dir():
emit([], args)
return
out = []
for path in sorted(_DATA_DIR.glob("*.json")):
rp_id = path.stem
try:
data = json.loads(path.read_text())
except Exception:
continue
if args.status and (data.get("status") or "") != args.status:
continue
out.append(_summarize(rp_id, data))
out.sort(key=lambda r: r.get("started_at") or "", reverse=True)
emit(out[: args.limit], args)
def cmd_show(args):
data = _load(args.id)
if data is None:
fail(f"no research session {args.id!r}")
emit(data, args)
def cmd_report(args):
data = _load(args.id)
if data is None:
fail(f"no research session {args.id!r}")
report = data.get("result") or data.get("raw_report") or ""
if args.raw:
sys.stdout.write(report)
if not report.endswith("\n"):
sys.stdout.write("\n")
return
emit({
"id": args.id,
"query": data.get("query") or "",
"report": report,
"sources": data.get("sources") or [],
}, args)
def cmd_search(args):
if not _DATA_DIR.is_dir():
emit([], args)
return
q = args.query.lower()
out = []
for path in _DATA_DIR.glob("*.json"):
rp_id = path.stem
try:
data = json.loads(path.read_text())
except Exception:
continue
haystack = " ".join([
(data.get("query") or "").lower(),
(data.get("result") or "").lower(),
(data.get("category") or "").lower(),
])
if q in haystack:
out.append(_summarize(rp_id, data))
out.sort(key=lambda r: r.get("started_at") or "", reverse=True)
emit(out[: args.limit], args)
def cmd_delete(args):
path = _DATA_DIR / f"{args.id}.json"
if not path.exists():
fail(f"no research session {args.id!r}")
snap_summary = _summarize(args.id, _load(args.id) or {})
path.unlink()
emit({"ok": True, "deleted": snap_summary}, args)
def _build_parser():
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--pretty", action="store_true")
p = argparse.ArgumentParser(prog="odysseus-research", parents=[common])
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", parents=[common])
pl.add_argument("--status", choices=["complete", "running", "cancelled", "error"])
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)
pr = sub.add_parser("report", parents=[common])
pr.add_argument("id")
pr.add_argument("--raw", action="store_true", help="write raw markdown to stdout")
pr.set_defaults(func=cmd_report)
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()))

147
scripts/odysseus-sessions Executable file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""odysseus-sessions — shell wrapper for chat sessions.
odysseus-sessions list [--archived] [--folder F] [--limit N]
odysseus-sessions show SESSION_ID
odysseus-sessions archive SESSION_ID
odysseus-sessions unarchive SESSION_ID
odysseus-sessions delete SESSION_ID # hard-delete (irreversible)
"""
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, Session as DbSession
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 _serialize(s: "DbSession") -> dict:
return {
"id": s.id,
"name": s.name,
"model": s.model,
"endpoint_url": s.endpoint_url,
"owner": s.owner or "",
"folder": s.folder or "",
"archived": bool(s.archived),
"rag": bool(s.rag),
"is_important": bool(s.is_important),
"message_count": s.message_count or 0,
"total_input_tokens": s.total_input_tokens or 0,
"total_output_tokens": s.total_output_tokens or 0,
"last_accessed": s.last_accessed.isoformat() if s.last_accessed else "",
"created_at": s.created_at.isoformat() if s.created_at else "",
}
def cmd_list(args):
db = SessionLocal()
try:
q = db.query(DbSession)
if not args.archived:
q = q.filter(DbSession.archived == False) # noqa: E712
elif args.archived == "only":
q = q.filter(DbSession.archived == True) # noqa: E712
if args.folder:
q = q.filter(DbSession.folder == args.folder)
q = q.order_by(DbSession.last_accessed.desc()).limit(args.limit)
emit([_serialize(s) for s in q.all()], args)
finally:
db.close()
def cmd_show(args):
db = SessionLocal()
try:
s = db.get(DbSession, args.id)
if not s:
fail(f"no session with id {args.id!r}")
emit(_serialize(s), args)
finally:
db.close()
def cmd_archive(args):
_set_archived(args, True)
def cmd_unarchive(args):
_set_archived(args, False)
def _set_archived(args, archived: bool):
db = SessionLocal()
try:
s = db.get(DbSession, args.id)
if not s:
fail(f"no session with id {args.id!r}")
s.archived = archived
db.commit()
emit({"ok": True, "id": s.id, "archived": s.archived}, args)
finally:
db.close()
def cmd_delete(args):
if not args.yes:
fail("delete is irreversible — pass --yes to confirm")
db = SessionLocal()
try:
s = db.get(DbSession, args.id)
if not s:
fail(f"no session with id {args.id!r}")
snap = _serialize(s)
db.delete(s)
db.commit()
emit({"ok": True, "deleted": snap}, 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-sessions", parents=[common])
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", parents=[common])
pl.add_argument("--archived", nargs="?", const=True, default=False,
help="include archived; --archived only = only archived")
pl.add_argument("--folder")
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)
pa = sub.add_parser("archive", parents=[common])
pa.add_argument("id")
pa.set_defaults(func=cmd_archive)
pu = sub.add_parser("unarchive", parents=[common])
pu.add_argument("id")
pu.set_defaults(func=cmd_unarchive)
pd = sub.add_parser("delete", parents=[common])
pd.add_argument("id")
pd.add_argument("--yes", action="store_true", help="confirm deletion")
pd.set_defaults(func=cmd_delete)
return p
if __name__ == "__main__":
sys.exit(run(_build_parser()))

135
scripts/odysseus-signature Executable file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""odysseus-signature — shell wrapper for stored email signature images.
The web UI lets you draw a signature; the result lives as a PNG (and
optional SVG) in the `signatures` table, keyed by id + owner.
odysseus-signature list # all signatures (metadata only)
odysseus-signature show SIG_ID # full record incl. data_png
odysseus-signature export SIG_ID --png OUT # write PNG bytes to a file
odysseus-signature delete SIG_ID
"""
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, base64, json, logging, os, sys
from pathlib import Path
try:
from sqlalchemy import text
from core.database import engine
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 cmd_list(args):
"""No `Signature` SQLAlchemy model is registered for the
`signatures` table — query via raw SQL so we don't depend on it."""
with engine.connect() as conn:
rows = conn.execute(text(
"SELECT id, owner, name, width, height, "
" length(data_png) AS png_len, "
" svg IS NOT NULL AS has_svg, "
" created_at "
"FROM signatures ORDER BY created_at DESC"
)).mappings().all()
out = [
{
"id": r["id"],
"owner": r["owner"] or "",
"name": r["name"],
"width": r["width"],
"height": r["height"],
"png_bytes_approx": r["png_len"],
"has_svg": bool(r["has_svg"]),
"created_at": str(r["created_at"] or ""),
} for r in rows
]
emit(out, args)
def cmd_show(args):
with engine.connect() as conn:
row = conn.execute(text(
"SELECT id, owner, name, width, height, data_png, svg, created_at "
"FROM signatures WHERE id = :id"
), {"id": args.id}).mappings().first()
if not row:
fail(f"no signature with id {args.id!r}")
emit({
"id": row["id"],
"owner": row["owner"] or "",
"name": row["name"],
"width": row["width"],
"height": row["height"],
"data_png": row["data_png"],
"has_svg": bool(row["svg"]),
"created_at": str(row["created_at"] or ""),
}, args)
def cmd_export(args):
"""Decode the data_png column to bytes and write to `--png OUT`.
`data_png` is stored as a data URL (`data:image/png;base64,...`).
We strip the prefix, base64-decode, and write the bytes."""
with engine.connect() as conn:
row = conn.execute(text(
"SELECT data_png FROM signatures WHERE id = :id"
), {"id": args.id}).mappings().first()
if not row:
fail(f"no signature with id {args.id!r}")
raw = row["data_png"] or ""
if "," in raw:
raw = raw.split(",", 1)[1]
try:
png_bytes = base64.b64decode(raw)
except Exception as e:
fail(f"data_png is not valid base64: {e}")
out = Path(args.png)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(png_bytes)
emit({"ok": True, "id": args.id, "path": str(out), "bytes": len(png_bytes)}, args)
def cmd_delete(args):
with engine.begin() as conn:
res = conn.execute(text("DELETE FROM signatures WHERE id = :id"), {"id": args.id})
if res.rowcount == 0:
fail(f"no signature with id {args.id!r}")
emit({"ok": True, "id": args.id, "deleted": True}, args)
def _build_parser():
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--pretty", action="store_true")
p = argparse.ArgumentParser(prog="odysseus-signature", parents=[common])
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", parents=[common])
pl.set_defaults(func=cmd_list)
psh = sub.add_parser("show", parents=[common])
psh.add_argument("id")
psh.set_defaults(func=cmd_show)
pe = sub.add_parser("export", parents=[common])
pe.add_argument("id")
pe.add_argument("--png", required=True, help="output PNG path")
pe.set_defaults(func=cmd_export)
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()))

148
scripts/odysseus-skills Executable file
View File

@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""odysseus-skills — shell wrapper for AI skill scripts.
Skills are `SKILL.md` files under `data/skills/<category>/<name>/`. Each
captures a reusable how-to the assistant has learned. This CLI lets you
list, inspect, and prune them from the shell.
odysseus-skills list [--category general]
odysseus-skills show SKILL_NAME
odysseus-skills categories
odysseus-skills delete SKILL_NAME
odysseus-skills export SKILL_NAME --raw # full SKILL.md to stdout
"""
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, shutil, sys
from pathlib import Path
try:
from services.memory.skills import SkillsManager
quiet_logs()
except ModuleNotFoundError as e:
sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n")
sys.exit(2)
_DATA_DIR = str(_REPO_ROOT / "data")
_mgr: SkillsManager | None = None
def _manager() -> SkillsManager:
global _mgr
if _mgr is None:
_mgr = SkillsManager(_DATA_DIR)
return _mgr
def _summary(skill: dict) -> dict:
return {
"name": skill.get("name", ""),
"category": skill.get("category", "general"),
"description": (skill.get("description") or "")[:200],
"status": skill.get("status", ""),
"uses": skill.get("uses", 0),
"last_used": skill.get("last_used") or "",
"tags": skill.get("tags") or [],
}
def cmd_list(args):
out = _manager().load_all()
if args.category:
out = [s for s in out if (s.get("category") or "general") == args.category]
out.sort(key=lambda s: (-int(s.get("uses") or 0), s.get("name", "")))
emit([_summary(s) for s in out[: args.limit]], args)
def cmd_show(args):
for s in _manager().load_all():
if s.get("name") == args.name:
emit(s, args)
return
fail(f"no skill named {args.name!r}")
def cmd_categories(args):
counts: dict[str, int] = {}
for s in _manager().load_all():
c = s.get("category") or "general"
counts[c] = counts.get(c, 0) + 1
emit([{"category": c, "count": n} for c, n in sorted(counts.items())], args)
def cmd_delete(args):
# Locate the skill's directory and rm -rf it.
skills_root = Path(_DATA_DIR) / "skills"
for s in _manager().load_all():
if s.get("name") != args.name:
continue
cat = s.get("category") or "general"
path = skills_root / cat / s["name"]
if path.is_dir():
shutil.rmtree(path)
emit({"ok": True, "deleted": s.get("name"), "path": str(path)}, args)
return
fail(f"skill record found but directory missing: {path}")
fail(f"no skill named {args.name!r}")
def cmd_export(args):
for s in _manager().load_all():
if s.get("name") != args.name:
continue
cat = s.get("category") or "general"
md_path = Path(_DATA_DIR) / "skills" / cat / args.name / "SKILL.md"
if not md_path.exists():
fail(f"SKILL.md missing for {args.name!r}")
if args.raw:
sys.stdout.write(md_path.read_text())
return
emit({
"name": args.name,
"category": cat,
"path": str(md_path),
"content": md_path.read_text(),
}, args)
return
fail(f"no skill named {args.name!r}")
def _build_parser():
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--pretty", action="store_true")
p = argparse.ArgumentParser(prog="odysseus-skills", parents=[common])
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", parents=[common])
pl.add_argument("--category")
pl.add_argument("--limit", type=int, default=100)
pl.set_defaults(func=cmd_list)
psh = sub.add_parser("show", parents=[common])
psh.add_argument("name")
psh.set_defaults(func=cmd_show)
pc = sub.add_parser("categories", parents=[common])
pc.set_defaults(func=cmd_categories)
pd = sub.add_parser("delete", parents=[common])
pd.add_argument("name")
pd.set_defaults(func=cmd_delete)
pe = sub.add_parser("export", parents=[common])
pe.add_argument("name")
pe.add_argument("--raw", action="store_true", help="write raw SKILL.md to stdout")
pe.set_defaults(func=cmd_export)
return p
if __name__ == "__main__":
sys.exit(run(_build_parser()))

158
scripts/odysseus-tasks Executable file
View File

@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""odysseus-tasks — shell wrapper for the scheduled-tasks feature.
odysseus-tasks list [--status active|paused|completed] [--limit N]
odysseus-tasks show TASK_ID
odysseus-tasks pause TASK_ID
odysseus-tasks resume TASK_ID
odysseus-tasks runs [--task TASK_ID] [--limit N]
"""
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, ScheduledTask, TaskRun
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 _serialize_task(t: "ScheduledTask") -> dict:
return {
"id": t.id,
"name": t.name,
"task_type": t.task_type,
"action": t.action,
"prompt": (t.prompt or "")[:200] + ("…" if t.prompt and len(t.prompt) > 200 else ""),
"schedule": t.schedule,
"scheduled_time": t.scheduled_time,
"next_run": t.next_run.isoformat() if t.next_run else "",
"last_run": t.last_run.isoformat() if t.last_run else "",
"status": t.status,
"model": t.model,
"run_count": t.run_count or 0,
"cron_expression": t.cron_expression or "",
}
def _serialize_run(r: "TaskRun") -> dict:
return {
"id": r.id,
"task_id": r.task_id,
"started_at": r.started_at.isoformat() if r.started_at else "",
"completed_at": r.completed_at.isoformat() if r.completed_at else "",
"status": r.status,
"output_preview": (getattr(r, "output", "") or "")[:200],
}
def cmd_list(args):
db = SessionLocal()
try:
q = db.query(ScheduledTask)
if args.status:
q = q.filter(ScheduledTask.status == args.status)
q = q.order_by(ScheduledTask.next_run.asc().nulls_last()).limit(args.limit)
emit([_serialize_task(t) for t in q.all()], args)
finally:
db.close()
def cmd_show(args):
db = SessionLocal()
try:
t = db.get(ScheduledTask, args.id)
if not t:
fail(f"no task with id {args.id!r}")
out = _serialize_task(t)
# Include full prompt + recent runs for detail view
out["prompt_full"] = t.prompt or ""
out["endpoint_url"] = t.endpoint_url or ""
out["session_id"] = t.session_id or ""
out["webhook_token"] = "***" if t.webhook_token else ""
runs = db.query(TaskRun).filter(TaskRun.task_id == t.id).order_by(
TaskRun.started_at.desc()
).limit(5).all()
out["recent_runs"] = [_serialize_run(r) for r in runs]
emit(out, args)
finally:
db.close()
def cmd_pause(args):
_set_status(args, "paused")
def cmd_resume(args):
_set_status(args, "active")
def _set_status(args, new_status: str):
db = SessionLocal()
try:
t = db.get(ScheduledTask, args.id)
if not t:
fail(f"no task with id {args.id!r}")
t.status = new_status
db.commit()
db.refresh(t)
emit({"ok": True, "id": t.id, "status": t.status}, args)
finally:
db.close()
def cmd_runs(args):
db = SessionLocal()
try:
q = db.query(TaskRun)
if args.task:
q = q.filter(TaskRun.task_id == args.task)
q = q.order_by(TaskRun.started_at.desc()).limit(args.limit)
emit([_serialize_run(r) for r in q.all()], 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-tasks", parents=[common])
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", parents=[common])
pl.add_argument("--status", choices=["active", "paused", "completed"])
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)
pp = sub.add_parser("pause", parents=[common])
pp.add_argument("id")
pp.set_defaults(func=cmd_pause)
pr = sub.add_parser("resume", parents=[common])
pr.add_argument("id")
pr.set_defaults(func=cmd_resume)
prn = sub.add_parser("runs", parents=[common])
prn.add_argument("--task", help="filter by task id")
prn.add_argument("--limit", type=int, default=20)
prn.set_defaults(func=cmd_runs)
return p
if __name__ == "__main__":
sys.exit(run(_build_parser()))

195
scripts/odysseus-theme Executable file
View File

@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""odysseus-theme — shell wrapper for the theme system.
Built-in preset definitions live in `static/js/theme.js` (the `THEMES`
export). Each user's chosen preset + custom-color overrides persist
in `data/user_prefs.json` under that user's key, so we can read/write
themes from the shell without needing the browser.
odysseus-theme list # built-in preset names
odysseus-theme users # users with a saved theme
odysseus-theme get --user alice@example.com # one user's theme blob
odysseus-theme set --user alice@example.com --preset chatgpt
odysseus-theme export --user alice@example.com --pretty > theme.json
`set --preset NAME` clears any custom-color overrides — pass
`--keep-colors` to preserve them (useful when switching presets but
keeping your manual tweaks).
"""
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, shutil, subprocess, sys, tempfile
from pathlib import Path
_USER_PREFS_PATH = _REPO_ROOT / "data" / "user_prefs.json"
_THEME_JS = _REPO_ROOT / "static" / "js" / "theme.js"
def _load_prefs() -> dict:
if not _USER_PREFS_PATH.exists():
return {"_users": {}}
try:
data = json.loads(_USER_PREFS_PATH.read_text())
data.setdefault("_users", {})
return data
except json.JSONDecodeError as e:
fail(f"user_prefs.json is corrupt: {e}")
def _save_prefs(data: dict) -> None:
_USER_PREFS_PATH.parent.mkdir(parents=True, exist_ok=True)
# Backup so a bad write doesn't nuke everyone's theme.
if _USER_PREFS_PATH.exists():
try:
(_USER_PREFS_PATH.with_suffix(_USER_PREFS_PATH.suffix + ".bak")).write_bytes(
_USER_PREFS_PATH.read_bytes()
)
except Exception:
pass
tmp = _USER_PREFS_PATH.with_suffix(_USER_PREFS_PATH.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False))
tmp.replace(_USER_PREFS_PATH)
def _builtin_preset_names() -> list[str]:
"""Read the THEMES export from theme.js. Uses node so we don't
have to write a JS parser. Falls back to a regex scrape if node
isn't available."""
if not _THEME_JS.exists():
return []
if shutil.which("node"):
script = (
f"const m = await import('{_THEME_JS.as_posix()}'); "
"console.log(JSON.stringify(Object.keys(m.THEMES || {})));"
)
try:
out = subprocess.run(
["node", "--input-type=module", "-e", script],
capture_output=True, text=True, timeout=10,
)
if out.returncode == 0:
lines = [l for l in out.stdout.splitlines() if l.strip()]
if lines:
return json.loads(lines[-1])
except Exception:
pass
# Fallback: regex-scrape from the file. Less precise than node,
# but works without a JS runtime. We only match keys at depth 1
# (exactly 2 leading spaces) so nested `advanced: { userBubbleBg: ... }`
# entries don't leak into the preset list.
import re
src = _THEME_JS.read_text()
m = re.search(r"export\s+const\s+THEMES\s*=\s*\{(.*?)\n\};", src, re.DOTALL)
if not m:
return []
body = m.group(1)
return re.findall(r"^ ([a-zA-Z_][\w]*)\s*:", body, re.MULTILINE)
# ─── list ────────────────────────────────────────────────────────────
def cmd_list(args):
names = _builtin_preset_names()
emit({"presets": names, "count": len(names)}, args)
# ─── users ───────────────────────────────────────────────────────────
def cmd_users(args):
users = _load_prefs().get("_users", {})
out = []
for username, prefs in users.items():
theme = (prefs or {}).get("theme") or {}
out.append({
"user": username,
"preset": theme.get("name") or "",
"has_custom_colors": bool(theme.get("colors")),
"font": theme.get("font") or "",
"density": theme.get("density") or "",
})
out.sort(key=lambda r: r["user"])
emit(out, args)
# ─── get ─────────────────────────────────────────────────────────────
def cmd_get(args):
users = _load_prefs().get("_users", {})
prefs = users.get(args.user)
if prefs is None:
fail(f"no prefs for user {args.user!r}")
theme = (prefs or {}).get("theme") or {}
if not theme:
emit({"user": args.user, "theme": None}, args)
return
emit({"user": args.user, "theme": theme}, args)
# ─── set ─────────────────────────────────────────────────────────────
def cmd_set(args):
valid = _builtin_preset_names()
if valid and args.preset not in valid:
fail(
f"unknown preset {args.preset!r}. Available: {', '.join(valid) or '(none discovered)'}"
)
data = _load_prefs()
users = data.setdefault("_users", {})
user_prefs = users.setdefault(args.user, {})
theme = dict(user_prefs.get("theme") or {})
theme["name"] = args.preset
if not args.keep_colors:
theme.pop("colors", None)
user_prefs["theme"] = theme
_save_prefs(data)
emit({"ok": True, "user": args.user, "preset": args.preset, "kept_colors": args.keep_colors}, args)
# ─── export ──────────────────────────────────────────────────────────
def cmd_export(args):
users = _load_prefs().get("_users", {})
prefs = users.get(args.user)
if prefs is None:
fail(f"no prefs for user {args.user!r}")
emit(prefs.get("theme") or {}, args)
def _build_parser():
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--pretty", action="store_true")
p = argparse.ArgumentParser(prog="odysseus-theme", parents=[common])
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", parents=[common])
pl.set_defaults(func=cmd_list)
pu = sub.add_parser("users", parents=[common])
pu.set_defaults(func=cmd_users)
pg = sub.add_parser("get", parents=[common])
pg.add_argument("--user", required=True)
pg.set_defaults(func=cmd_get)
ps = sub.add_parser("set", parents=[common])
ps.add_argument("--user", required=True)
ps.add_argument("--preset", required=True)
ps.add_argument("--keep-colors", action="store_true",
help="don't clear custom color overrides")
ps.set_defaults(func=cmd_set)
pe = sub.add_parser("export", parents=[common])
pe.add_argument("--user", required=True)
pe.set_defaults(func=cmd_export)
return p
if __name__ == "__main__":
sys.exit(run(_build_parser()))

145
scripts/odysseus-webhook Executable file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""odysseus-webhook — shell wrapper for scheduled-task webhook tokens.
Tasks in the scheduled-task system can carry a `webhook_token`. Any
HTTP POST to `/api/webhook/<token>` fires the task. This CLI lists,
rotates, and revokes those tokens.
odysseus-webhook list # tasks that have a token
odysseus-webhook show TASK_ID
odysseus-webhook rotate TASK_ID # generate a fresh token
odysseus-webhook revoke TASK_ID # remove the token
odysseus-webhook url TASK_ID --base https://app.example.com
"""
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, secrets, sys
from pathlib import Path
try:
from core.database import SessionLocal, ScheduledTask
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 _summary(t: "ScheduledTask", reveal: bool = False) -> dict:
tok = t.webhook_token or ""
return {
"task_id": t.id,
"name": t.name,
"status": t.status,
"task_type": t.task_type,
"webhook_token": tok if reveal else (tok[:6] + "…" + tok[-4:]) if tok else "",
"has_token": bool(tok),
}
def cmd_list(args):
db = SessionLocal()
try:
rows = db.query(ScheduledTask).filter(ScheduledTask.webhook_token.isnot(None)).all()
emit([_summary(t, args.reveal) for t in rows], args)
finally:
db.close()
def cmd_show(args):
db = SessionLocal()
try:
t = db.get(ScheduledTask, args.id)
if not t:
fail(f"no task with id {args.id!r}")
emit(_summary(t, args.reveal), args)
finally:
db.close()
def cmd_rotate(args):
db = SessionLocal()
try:
t = db.get(ScheduledTask, args.id)
if not t:
fail(f"no task with id {args.id!r}")
# 32 bytes urlsafe → 43-char token, plenty of entropy.
t.webhook_token = secrets.token_urlsafe(32)
db.commit()
emit({"ok": True, "task_id": t.id, "webhook_token": t.webhook_token}, args)
finally:
db.close()
def cmd_revoke(args):
db = SessionLocal()
try:
t = db.get(ScheduledTask, args.id)
if not t:
fail(f"no task with id {args.id!r}")
old = t.webhook_token
t.webhook_token = None
db.commit()
emit({"ok": True, "task_id": t.id, "revoked": bool(old)}, args)
finally:
db.close()
def cmd_url(args):
db = SessionLocal()
try:
t = db.get(ScheduledTask, args.id)
if not t:
fail(f"no task with id {args.id!r}")
if not t.webhook_token:
fail(f"task {args.id!r} has no webhook token (rotate one first)")
base = (args.base or "http://localhost:7000").rstrip("/")
url = f"{base}/api/webhook/{t.webhook_token}"
emit({
"task_id": t.id,
"name": t.name,
"url": url,
"curl": f"curl -X POST {url}",
}, 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-webhook", parents=[common])
sub = p.add_subparsers(dest="cmd", required=True)
pl = sub.add_parser("list", parents=[common])
pl.add_argument("--reveal", action="store_true", help="show full tokens")
pl.set_defaults(func=cmd_list)
psh = sub.add_parser("show", parents=[common])
psh.add_argument("id")
psh.add_argument("--reveal", action="store_true")
psh.set_defaults(func=cmd_show)
pr = sub.add_parser("rotate", parents=[common])
pr.add_argument("id")
pr.set_defaults(func=cmd_rotate)
prv = sub.add_parser("revoke", parents=[common])
prv.add_argument("id")
prv.set_defaults(func=cmd_revoke)
pu = sub.add_parser("url", parents=[common])
pu.add_argument("id")
pu.add_argument("--base", help="base URL (default http://localhost:7000)")
pu.set_defaults(func=cmd_url)
return p
if __name__ == "__main__":
sys.exit(run(_build_parser()))

281
scripts/update_database.py Normal file
View File

@@ -0,0 +1,281 @@
"""
update_database.py
This script updates the database schema by adding new columns to the sessions table
and populating them with appropriate values. It handles SQLite's limitations
with ALTER TABLE operations by checking if columns exist before attempting to add them.
The following columns are added:
- last_accessed (DateTime): Set to created_at for existing records
- is_important (Boolean): Set to False for existing records
- message_count (Integer): Calculated from the number of messages in chat_messages table
Usage:
python update_database.py
"""
import sqlite3
import os
from datetime import datetime
from sqlalchemy import create_engine, inspect, text
from database import DATABASE_URL, SessionLocal, Base
def check_column_exists(engine, table_name, column_name):
"""Check if a column exists in a table."""
inspector = inspect(engine)
columns = inspector.get_columns(table_name)
return any(col['name'] == column_name for col in columns)
def add_column_sqlite(db_path, table_name, column_name, column_type, default_value=None):
"""
Add a column to a SQLite table by creating a new table, copying data, and renaming.
This is necessary because SQLite has limited ALTER TABLE support.
"""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Get current table info
cursor.execute(f"PRAGMA table_info({table_name})")
columns = cursor.fetchall()
column_names = [col[1] for col in columns]
# Create new table with additional column
new_table_name = f"{table_name}_new"
# Build new column list
new_columns = []
for col in columns:
new_columns.append(f"{col[1]} {col[2]}")
# Add the new column
new_column_def = f"{column_name} {column_type}"
if default_value is not None:
new_column_def += f" DEFAULT {default_value}"
new_columns.append(new_column_def)
# Create new table
columns_sql = ", ".join(new_columns)
create_sql = f"CREATE TABLE {new_table_name} ({columns_sql})"
cursor.execute(create_sql)
# Copy data from old table to new table
column_names_str = ", ".join(column_names)
insert_sql = f"INSERT INTO {new_table_name} ({column_names_str}) SELECT {column_names_str} FROM {table_name}"
cursor.execute(insert_sql)
# Drop old table and rename new table
cursor.execute(f"DROP TABLE {table_name}")
cursor.execute(f"ALTER TABLE {new_table_name} RENAME TO {table_name}")
conn.commit()
conn.close()
def update_database():
"""Update the database schema and populate new columns."""
# Create engine from DATABASE_URL
engine = create_engine(DATABASE_URL)
# Extract database path from DATABASE_URL for SQLite
db_path = None
if "sqlite" in DATABASE_URL:
db_path = DATABASE_URL.replace("sqlite:///", "")
# Handle relative paths
if not os.path.isabs(db_path):
db_path = os.path.join(os.path.dirname(__file__), db_path)
print(f"Updating database at: {DATABASE_URL}")
# Start a transaction
db = SessionLocal()
try:
# Add last_accessed column if it doesn't exist
if not check_column_exists(engine, 'sessions', 'last_accessed'):
print("Adding last_accessed column...")
if db_path: # SQLite
add_column_sqlite(db_path, 'sessions', 'last_accessed', 'DATETIME')
else: # Other databases
with engine.connect() as conn:
conn.execute(text("ALTER TABLE sessions ADD COLUMN last_accessed DATETIME"))
conn.commit()
# Add is_important column if it doesn't exist
if not check_column_exists(engine, 'sessions', 'is_important'):
print("Adding is_important column...")
if db_path: # SQLite
add_column_sqlite(db_path, 'sessions', 'is_important', 'BOOLEAN', '0')
else: # Other databases
with engine.connect() as conn:
conn.execute(text("ALTER TABLE sessions ADD COLUMN is_important BOOLEAN DEFAULT FALSE"))
conn.commit()
# Add message_count column if it doesn't exist
if not check_column_exists(engine, 'sessions', 'message_count'):
print("Adding message_count column...")
if db_path: # SQLite
add_column_sqlite(db_path, 'sessions', 'message_count', 'INTEGER', '0')
else: # Other databases
with engine.connect() as conn:
conn.execute(text("ALTER TABLE sessions ADD COLUMN message_count INTEGER DEFAULT 0"))
conn.commit()
# Populate last_accessed with created_at for existing records where last_accessed is NULL
print("Populating last_accessed column...")
with engine.connect() as conn:
conn.execute(text("""
UPDATE sessions
SET last_accessed = created_at
WHERE last_accessed IS NULL
"""))
conn.commit()
# Populate is_important with FALSE for existing records where is_important is NULL
print("Populating is_important column...")
with engine.connect() as conn:
conn.execute(text("""
UPDATE sessions
SET is_important = 0
WHERE is_important IS NULL
"""))
conn.commit()
# Calculate and populate message_count from chat_messages table
print("Calculating and populating message_count column...")
with engine.connect() as conn:
# First, set all message_count to 0
conn.execute(text("UPDATE sessions SET message_count = 0"))
# Then, count messages for each session and update
conn.execute(text("""
UPDATE sessions
SET message_count = (
SELECT COUNT(*)
FROM chat_messages
WHERE chat_messages.session_id = sessions.id
)
"""))
conn.commit()
print("Database update completed successfully!")
except Exception as e:
print(f"Error updating database: {e}")
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
update_database()
"""
update_database.py
This script updates the database schema by adding new columns to the sessions table
if they don't already exist. It uses raw SQL ALTER TABLE statements to modify
the existing SQLite database.
The following columns are added:
- last_accessed (DateTime): Set to created_at for existing records
- is_important (Boolean): Set to False for existing records
- message_count (Integer): Calculated from the number of messages in chat_messages table
Usage:
python update_database.py
"""
import os
from datetime import datetime
from sqlalchemy import create_engine, text
from database import DATABASE_URL, SessionLocal
def update_database():
"""Update the database schema and populate new columns."""
# Create engine from DATABASE_URL
engine = create_engine(DATABASE_URL)
# Start a transaction
db = SessionLocal()
try:
# Add last_accessed column if it doesn't exist
try:
with engine.connect() as conn:
conn.execute(text("ALTER TABLE sessions ADD COLUMN last_accessed DATETIME"))
conn.commit()
print("Added last_accessed column to sessions table")
except Exception as e:
if "duplicate column name" in str(e).lower():
print("last_accessed column already exists")
else:
print(f"Error adding last_accessed column: {e}")
# Add is_important column if it doesn't exist
try:
with engine.connect() as conn:
conn.execute(text("ALTER TABLE sessions ADD COLUMN is_important BOOLEAN DEFAULT FALSE"))
conn.commit()
print("Added is_important column to sessions table")
except Exception as e:
if "duplicate column name" in str(e).lower():
print("is_important column already exists")
else:
print(f"Error adding is_important column: {e}")
# Add message_count column if it doesn't exist
try:
with engine.connect() as conn:
conn.execute(text("ALTER TABLE sessions ADD COLUMN message_count INTEGER DEFAULT 0"))
conn.commit()
print("Added message_count column to sessions table")
except Exception as e:
if "duplicate column name" in str(e).lower():
print("message_count column already exists")
else:
print(f"Error adding message_count column: {e}")
# Populate last_accessed with created_at for existing records where last_accessed is NULL
print("Populating last_accessed column...")
with engine.connect() as conn:
conn.execute(text("""
UPDATE sessions
SET last_accessed = created_at
WHERE last_accessed IS NULL
"""))
conn.commit()
# Populate is_important with FALSE for existing records where is_important is NULL
print("Populating is_important column...")
with engine.connect() as conn:
conn.execute(text("""
UPDATE sessions
SET is_important = 0
WHERE is_important IS NULL
"""))
conn.commit()
# Calculate and populate message_count from chat_messages table
print("Calculating and populating message_count column...")
with engine.connect() as conn:
# First, set all message_count to 0
conn.execute(text("UPDATE sessions SET message_count = 0"))
# Then, count messages for each session and update
conn.execute(text("""
UPDATE sessions
SET message_count = (
SELECT COUNT(*)
FROM chat_messages
WHERE chat_messages.session_id = sessions.id
)
"""))
conn.commit()
print("Database update completed successfully!")
except Exception as e:
print(f"Error updating database: {e}")
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
update_database()