From 93fa05bdcc72918cb93423c2b5bebeea6a4d7f21 Mon Sep 17 00:00:00 2001 From: marvin Date: Mon, 28 Sep 2026 20:06:02 +0200 Subject: [PATCH] Add custom 'marvin' persona as interactive default Server-only deployment has no console admin API, so the persona is upserted directly into the SQLite DB via scripts/persona.py with the prompt text versioned in the repo (VM persistence rules, Gitea/GitHub remotes, rootless podman). Built-in personas are seeded by migration 063; this sets marvin as the interactive default. --- README.md | 17 +++++++ scripts/persona.py | 110 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 scripts/persona.py diff --git a/README.md b/README.md index 3d1200d..b8b468d 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,23 @@ Edit `configs/turnstone/config.toml` (gitignored; see (currently mirrored from opencode's `auth.json`). - `[auth] jwt_secret` — required, generate once. - `[database] path` — SQLite on the `data/` share. +- `[tools]` — `workspace_dir` surfaced to the model + (`/home/marvin/work`, mounted from `./work`). + +### Personas + +Built-ins (Engineer, Writer, Researcher, Scribe, Manager, Executive) +are seeded by turnstone's migration. This deployment adds a custom +default persona `marvin` (VM-specific: persistence rules, Gitea remotes, +podman) via `scripts/persona.py` — the prompt text is versioned there +and re-applied after a DB reset: + +```sh +python3 scripts/persona.py # --db data/turnstone.db default +``` + +(Without a console deployment, persona CRUD is a direct SQLite write; +with `turnstone-console` running, use its admin API/Personas tab.) ## Notes / trade-offs diff --git a/scripts/persona.py b/scripts/persona.py new file mode 100644 index 0000000..c378f48 --- /dev/null +++ b/scripts/persona.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Upsert the 'marvin' interactive persona into the turnstone DB. + +The deployment runs turnstone-server only (no console), so persona +management happens directly on the SQLite database shared into the VM +(data/turnstone.db, from the host). The persona text is versioned here +so it can be re-applied after a DB reset. + +Usage: python3 scripts/persona.py [--db data/turnstone.db] +""" +from __future__ import annotations + +import argparse +import datetime +import json +import sqlite3 +import sys + +PERSONA_ID = "op-marvin" +NAME = "marvin" +DISPLAY_NAME = "Marvin (VM)" +DESCRIPTION = ( + "Coding agent tailored to this container-VM: workspace persistence " + "rules, Gitea/GitHub remotes, rootless podman." +) +APPLIES_TO_KINDS = ["interactive"] + +BASE_PROMPT = """\ +You are Marvin, a software engineer working inside a disposable Arch \ +Linux VM. + +Environment facts that shape how you work: + +- Your workspace is /home/marvin/work — shared with the host and the \ +only place that survives a VM restart. Keep all valuable work there, \ +or push it to a git remote. Anything else you create (installed \ +packages, pip installs, git clones outside ~/work) is discarded when \ +the VM shuts down. +- Git remotes live on self-hosted Gitea at git.nubo.sh (use the `tea` \ +CLI) and GitHub (use `gh`). Open pull requests instead of committing \ +to main unless asked. +- Rootless podman is available for containerized builds and tests. +- The turnstone web UI and the database live on the host side and are \ +not yours to manage. + +Work like an engineer: read before you edit, verify before you commit, \ +diagnose before you retry. When a request is ambiguous, make a \ +reasonable call and state what you assumed. When you're uncertain, say \ +so. Report what you did and what you didn't — finished work should be \ +reviewable without archaeology.\ +""" + + +def upsert(db_path: str) -> None: + now = datetime.datetime.now().isoformat(timespec="seconds") + con = sqlite3.connect(db_path, timeout=10) + try: + with con: + con.execute( + """ + INSERT INTO personas ( + persona_id, name, display_name, description, + base_prompt, base_prompt_file, tool_allowlist, + mcp_enabled, memory_enabled, applies_to_kinds, + is_default, enabled, org_id, created_by, + created, updated + ) VALUES (?, ?, ?, ?, ?, NULL, NULL, 1, 1, ?, 1, 1, '', ?, + ?, ?) + ON CONFLICT(persona_id) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + base_prompt = excluded.base_prompt, + applies_to_kinds = excluded.applies_to_kinds, + is_default = 1, + enabled = 1, + updated = excluded.updated + """, + ( + PERSONA_ID, + NAME, + DISPLAY_NAME, + DESCRIPTION, + BASE_PROMPT, + json.dumps(APPLIES_TO_KINDS), + "marvin", + now, + now, + ), + ) + # Exactly one default per kind: demote the incumbent. + con.execute( + """UPDATE personas SET is_default = 0, updated = ? + WHERE is_default = 1 + AND applies_to_kinds LIKE '%interactive%' + AND persona_id != ?""", + (now, PERSONA_ID), + ) + finally: + con.close() + print(f"persona '{NAME}' is_default for interactive in {db_path}") + + +if __name__ == "__main__": + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--db", default="data/turnstone.db") + args = ap.parse_args() + try: + upsert(args.db) + except sqlite3.Error as e: + sys.exit(f"persona upsert failed: {e}")