Odysseus v1.0
This commit is contained in:
92
scripts/_completion/odysseus.bash
Normal file
92
scripts/_completion/odysseus.bash
Normal 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
|
||||
72
scripts/_completion/odysseus.zsh
Normal file
72
scripts/_completion/odysseus.zsh
Normal 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
0
scripts/_lib/__init__.py
Normal file
122
scripts/_lib/cli.py
Normal file
122
scripts/_lib/cli.py
Normal 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
234
scripts/add_hwfit_models.py
Normal 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()
|
||||
88
scripts/claim_ownerless.py
Normal file
88
scripts/claim_ownerless.py
Normal 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()
|
||||
88
scripts/demo_email/demo_account.py
Executable file
88
scripts/demo_email/demo_account.py
Executable 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
71
scripts/demo_email/manage.sh
Executable 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
|
||||
394
scripts/demo_email/seed_demo_emails.py
Executable file
394
scripts/demo_email/seed_demo_emails.py
Executable 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
1095
scripts/diffusion_server.py
Normal file
File diff suppressed because it is too large
Load Diff
39
scripts/encode_previews.sh
Executable file
39
scripts/encode_previews.sh
Executable 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
9
scripts/fix_paths.py
Normal 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
182
scripts/hf_download.py
Normal 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
114
scripts/index_documents.py
Normal 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()
|
||||
142
scripts/migrate_faiss_to_chroma.py
Normal file
142
scripts/migrate_faiss_to_chroma.py
Normal 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
130
scripts/odysseus
Executable 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
218
scripts/odysseus-backup
Executable 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
249
scripts/odysseus-calendar
Executable 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
143
scripts/odysseus-contacts
Executable 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
463
scripts/odysseus-cookbook
Executable 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
199
scripts/odysseus-docs
Executable 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
165
scripts/odysseus-gallery
Executable 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
145
scripts/odysseus-logs
Executable 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
395
scripts/odysseus-mail
Executable 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
196
scripts/odysseus-mcp
Executable 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
153
scripts/odysseus-memory
Executable 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
164
scripts/odysseus-notes
Executable 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
123
scripts/odysseus-personal
Executable 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
129
scripts/odysseus-preset
Executable 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
160
scripts/odysseus-research
Executable 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
147
scripts/odysseus-sessions
Executable 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
135
scripts/odysseus-signature
Executable 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
148
scripts/odysseus-skills
Executable 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
158
scripts/odysseus-tasks
Executable 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
195
scripts/odysseus-theme
Executable 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
145
scripts/odysseus-webhook
Executable 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
281
scripts/update_database.py
Normal 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()
|
||||
Reference in New Issue
Block a user