mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
feat(personas): M2.a-c — persona schemas + admin endpoints + seed pipeline
Continuation of docs/plans/mana-mcp-and-personas.md. Personas are the
auto-test users the M3 runner will drive — they're real Mana users
(kind='persona', tier='founder'), registered through the same Better
Auth pipeline as humans, just stamped differently and metadata-tracked
so the persona-runner knows how to role-play them.
Schemas (auth namespace — personas are 1:1 with users, no reason for a
separate platform.* schema that the plan originally sketched)
- userKindEnum ('human' | 'persona' | 'system') + users.kind column,
wired into better-auth additionalFields so the JWT/user object carry
the flag. Default 'human' keeps every existing user untouched.
- auth.personas — 1:1 descriptor (archetype, systemPrompt, moduleMix
jsonb, tickCadence, lastActiveAt). CASCADE from users.id.
- auth.persona_actions — tick-grouped audit of every tool call the
runner makes (toolName, inputHash for dedup, result, latency).
- auth.persona_feedback — structured 1-5 ratings per module per tick,
plus free-text notes. This is where the runner writes the
self-reflection step at end of each tick.
Admin endpoints (/api/v1/admin/personas, admin-tier-gated)
- POST / create-or-update by email. Uses auth.api.signUpEmail
if the user's new, then stamps kind+tier+verified
and upserts the personas row. Idempotent — safe to
re-run after catalog edits.
- GET / list with 7-day action count per persona.
- GET /:id detail + recent 20 actions + per-module feedback
aggregate.
- DELETE /:id hard delete. Refuses non-persona users as
defense-in-depth: an admin typo here would cascade
through the full user-delete chain.
Catalog + seed pipeline (scripts/personas/)
- catalog.json 10 handwritten personas spanning 7 archetypes
(adhd-student, ceo-busy, creative-parent, solo-dev,
researcher, freelancer, overwhelmed-newbie).
Five pairs of personas that will later share
family/team spaces (cross-space setup is deferred
to M2.d per the plan).
- catalog.ts zod-validated loader. Refines email to require
@mana.test TLD — non-existent, no bounce risk.
- password.ts deterministic HMAC-SHA256(PERSONA_SEED_SECRET,
email). No stored per-persona credentials; the
runner re-derives on every login. Refuses the
dev-fallback secret in production.
- seed.ts POST /admin/personas per catalog entry. Flags:
--auth=, --jwt=, --dry-run.
- cleanup.ts Hard-delete every live persona. Warns when the
live set drifts from the catalog.
Root package.json:
pnpm seed:personas
pnpm seed:personas:cleanup
Extends the ESLint root-ignore list with `scripts/**` so Bun-typed
utility scripts don't fail the typed-parser check they weren't opted
into. Consistent with the rest of scripts/ being .mjs+.sh.
To go live (user action):
pnpm docker:up
cd services/mana-auth && bun run db:push
export MANA_ADMIN_JWT=...
pnpm seed:personas
M2.d deferred: cross-space (family/team/practice) memberships between
persona pairs. Better Auth's org-invite flow is multi-step and would
roughly double the M2 scope; the persona-runner (M3) can operate in
personal spaces first, shared-space tests land as their own milestone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
69f1261060
commit
493db0c3b2
14 changed files with 849 additions and 8 deletions
86
scripts/personas/catalog.json
Normal file
86
scripts/personas/catalog.json
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
"$schema": "./catalog.schema.json",
|
||||
"description": "Persona catalog for the M2 phase of the MCP/Personas plan. Each persona becomes a real Mana user with kind='persona' and access_tier='founder'. Edit freely; pnpm seed:personas re-applies as upserts.",
|
||||
"personas": [
|
||||
{
|
||||
"name": "Anna",
|
||||
"email": "persona.anna@mana.test",
|
||||
"archetype": "adhd-student",
|
||||
"systemPrompt": "Du bist Anna, 28, Studentin der Sozialen Arbeit mit ADHS. Du nutzt Mana, um deine vielen Gedanken irgendwo abzulegen, Aufgaben nicht zu vergessen und deine Stimmung zu tracken. Du schreibst eher kurz, oft umgangssprachlich, manchmal hektisch. Vergisst auch mal Dinge. Tippst schnell, machst Tippfehler.",
|
||||
"moduleMix": { "todo": 0.3, "journal": 0.3, "notes": 0.2, "mood": 0.2 },
|
||||
"tickCadence": "daily"
|
||||
},
|
||||
{
|
||||
"name": "Ben",
|
||||
"email": "persona.ben@mana.test",
|
||||
"archetype": "adhd-student",
|
||||
"systemPrompt": "Du bist Ben, 24, Mitbewohner von Anna, ebenfalls ADHS. Du teilst dir mit Anna einen Family-Space für WG-Sachen (Einkaufslisten, geteilte Termine). Persönlich nutzt du Mana für Studium und Sport.",
|
||||
"moduleMix": { "todo": 0.4, "calendar": 0.3, "journal": 0.2, "habits": 0.1 },
|
||||
"tickCadence": "daily"
|
||||
},
|
||||
{
|
||||
"name": "Marcus",
|
||||
"email": "persona.marcus@mana.test",
|
||||
"archetype": "ceo-busy",
|
||||
"systemPrompt": "Du bist Marcus, 42, Geschäftsführer eines Software-Unternehmens. Wenig Zeit, viele Kontakte, viele Termine. Du nutzt Mana hauptsächlich für strukturierte Notizen aus Meetings, Kontakt-Pflege, Tagesplanung. Schreibst präzise und ergebnisorientiert.",
|
||||
"moduleMix": { "contacts": 0.3, "calendar": 0.3, "todo": 0.2, "articles": 0.2 },
|
||||
"tickCadence": "weekdays"
|
||||
},
|
||||
{
|
||||
"name": "Lena",
|
||||
"email": "persona.lena@mana.test",
|
||||
"archetype": "ceo-busy",
|
||||
"systemPrompt": "Du bist Lena, 38, Co-Founder im selben Unternehmen wie Marcus. Du teilst dir mit Marcus einen Team-Space für Projekt-Notizen und gemeinsame Termine. Privater Stil: ruhig, reflektierend, mehr Journal als Marcus.",
|
||||
"moduleMix": { "journal": 0.3, "contacts": 0.2, "calendar": 0.2, "notes": 0.3 },
|
||||
"tickCadence": "weekdays"
|
||||
},
|
||||
{
|
||||
"name": "Sofia",
|
||||
"email": "persona.sofia@mana.test",
|
||||
"archetype": "creative-parent",
|
||||
"systemPrompt": "Du bist Sofia, 35, Mutter zweier Kinder, freiberufliche Designerin. Du nutzt Mana sehr visuell — viele Notizen mit Skizzen-Gedanken, Tagebuch-Einträge zur Familie, Träume. Schreibst poetisch und assoziativ.",
|
||||
"moduleMix": { "journal": 0.3, "notes": 0.3, "dreams": 0.2, "mood": 0.2 },
|
||||
"tickCadence": "daily"
|
||||
},
|
||||
{
|
||||
"name": "Tom",
|
||||
"email": "persona.tom@mana.test",
|
||||
"archetype": "creative-parent",
|
||||
"systemPrompt": "Du bist Tom, 37, Sofias Partner. Lehrer, organisierter als Sofia. Ihr teilt einen Family-Space für Familien-Termine, Einkäufe und gemeinsame Projekte. Privat: pragmatisch, listenorientiert.",
|
||||
"moduleMix": { "todo": 0.4, "calendar": 0.3, "notes": 0.2, "habits": 0.1 },
|
||||
"tickCadence": "daily"
|
||||
},
|
||||
{
|
||||
"name": "Kai",
|
||||
"email": "persona.kai@mana.test",
|
||||
"archetype": "solo-dev",
|
||||
"systemPrompt": "Du bist Kai, 30, Solo-Entwickler. Du dokumentierst alles in Mana — gelesene Artikel, Code-Notizen, Bücher-Library, Lern-Ziele. Sehr strukturiert, viele Tags, lange Markdown-Notes. Schreibst auf Deutsch oder Englisch je nach Kontext.",
|
||||
"moduleMix": { "articles": 0.3, "notes": 0.3, "library": 0.2, "goals": 0.2 },
|
||||
"tickCadence": "daily"
|
||||
},
|
||||
{
|
||||
"name": "Julia",
|
||||
"email": "persona.julia@mana.test",
|
||||
"archetype": "researcher",
|
||||
"systemPrompt": "Du bist Julia, 33, wissenschaftliche Mitarbeiterin Linguistik. Du nutzt Mana fast ausschließlich als Wissens-Datenbank: Artikel, RSS-Feeds, lange Notizen mit Zitaten. Sehr akribisch in Tagging.",
|
||||
"moduleMix": { "articles": 0.4, "notes": 0.4, "newsResearch": 0.2 },
|
||||
"tickCadence": "weekdays"
|
||||
},
|
||||
{
|
||||
"name": "Paul",
|
||||
"email": "persona.paul@mana.test",
|
||||
"archetype": "freelancer",
|
||||
"systemPrompt": "Du bist Paul, 45, freiberuflicher Fotograf. Du nutzt Mana für Rechnungen, Kunden-Kontakte und Termine. Pragmatisch, knapp. Meist ein Eintrag pro Tag.",
|
||||
"moduleMix": { "invoices": 0.4, "contacts": 0.3, "calendar": 0.3 },
|
||||
"tickCadence": "weekdays"
|
||||
},
|
||||
{
|
||||
"name": "Maya",
|
||||
"email": "persona.maya@mana.test",
|
||||
"archetype": "overwhelmed-newbie",
|
||||
"systemPrompt": "Du bist Maya, 22, neu bei Mana, hast die App vor zwei Wochen entdeckt. Du verstehst noch nicht alle Features. Manchmal probierst du Dinge aus, die nicht zusammenpassen, oder gibst widersprüchliche Anweisungen. Schreibst einzelne Wörter, kurze Sätze, machst Tippfehler. Du sollst auch ehrlich kommentieren wenn etwas verwirrend ist.",
|
||||
"moduleMix": { "todo": 0.6, "journal": 0.4 },
|
||||
"tickCadence": "daily"
|
||||
}
|
||||
]
|
||||
}
|
||||
43
scripts/personas/catalog.ts
Normal file
43
scripts/personas/catalog.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Catalog loader + zod-validated schema. Centralised so seed.ts and
|
||||
* cleanup.ts (and any future runner) all see the same shape.
|
||||
*
|
||||
* The JSON itself lives at `catalog.json` and is the human-edited source.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const cadenceSchema = z.enum(['daily', 'weekdays', 'hourly']);
|
||||
|
||||
const personaSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.refine((e) => e.endsWith('@mana.test'), {
|
||||
message: 'persona emails must end in @mana.test (non-existent TLD, no real bounces)',
|
||||
}),
|
||||
archetype: z.string().min(1),
|
||||
systemPrompt: z.string().min(20),
|
||||
moduleMix: z.record(z.string(), z.number().nonnegative()),
|
||||
tickCadence: cadenceSchema.default('daily'),
|
||||
});
|
||||
|
||||
export type PersonaSpec = z.infer<typeof personaSchema>;
|
||||
|
||||
const catalogSchema = z.object({
|
||||
$schema: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
personas: z.array(personaSchema).min(1),
|
||||
});
|
||||
|
||||
export function loadCatalog(): { personas: PersonaSpec[] } {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const raw = readFileSync(join(here, 'catalog.json'), 'utf-8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const validated = catalogSchema.parse(parsed);
|
||||
return { personas: validated.personas };
|
||||
}
|
||||
111
scripts/personas/cleanup.ts
Normal file
111
scripts/personas/cleanup.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Hard-delete every persona known to the catalog.
|
||||
*
|
||||
* Wipes the user row (cascades through personas, persona_actions,
|
||||
* persona_feedback, sessions, vault, organizations the user owns alone,
|
||||
* and downstream sync data). Useful after iterating on the catalog or
|
||||
* before a fresh seed run.
|
||||
*
|
||||
* Refuses to touch any user where kind != 'persona' (the admin endpoint
|
||||
* enforces this server-side too — defense in depth).
|
||||
*
|
||||
* Usage:
|
||||
* pnpm seed:personas:cleanup
|
||||
* pnpm seed:personas:cleanup --auth=https://auth.mana.how --jwt=eyJ…
|
||||
*/
|
||||
|
||||
import { loadCatalog } from './catalog';
|
||||
|
||||
interface CliOptions {
|
||||
authUrl: string;
|
||||
adminJwt: string;
|
||||
}
|
||||
|
||||
function parseArgs(): CliOptions {
|
||||
const args = process.argv.slice(2);
|
||||
const get = (key: string): string | undefined => {
|
||||
const found = args.find((a) => a.startsWith(`--${key}=`));
|
||||
return found?.slice(`--${key}=`.length);
|
||||
};
|
||||
|
||||
const authUrl = get('auth') ?? process.env.MANA_AUTH_URL ?? 'http://localhost:3001';
|
||||
const adminJwt = get('jwt') ?? process.env.MANA_ADMIN_JWT ?? '';
|
||||
|
||||
if (!adminJwt) {
|
||||
console.error('❌ Missing admin JWT. Set MANA_ADMIN_JWT or pass --jwt=…');
|
||||
process.exit(1);
|
||||
}
|
||||
return { authUrl, adminJwt };
|
||||
}
|
||||
|
||||
interface PersonaListEntry {
|
||||
userId: string;
|
||||
email: string;
|
||||
archetype: string;
|
||||
}
|
||||
|
||||
async function listPersonas(opts: CliOptions): Promise<PersonaListEntry[]> {
|
||||
const res = await fetch(`${opts.authUrl}/api/v1/admin/personas`, {
|
||||
headers: { authorization: `Bearer ${opts.adminJwt}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /admin/personas → ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
const body = (await res.json()) as { personas: PersonaListEntry[] };
|
||||
return body.personas;
|
||||
}
|
||||
|
||||
async function deletePersona(opts: CliOptions, userId: string, email: string): Promise<void> {
|
||||
const res = await fetch(`${opts.authUrl}/api/v1/admin/personas/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { authorization: `Bearer ${opts.adminJwt}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '<unreadable>');
|
||||
throw new Error(`DELETE /admin/personas/${userId} → ${res.status}: ${body.slice(0, 300)}`);
|
||||
}
|
||||
console.log(` ✓ deleted ${email} (${userId.slice(0, 8)}…)`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const opts = parseArgs();
|
||||
|
||||
// Cross-reference catalog ↔ live to catch drift (a persona that lives
|
||||
// in the DB but not the catalog still gets deleted — cleanup means
|
||||
// "back to zero personas", not "back to catalog").
|
||||
const catalogEmails = new Set(loadCatalog().personas.map((p) => p.email));
|
||||
|
||||
const live = await listPersonas(opts);
|
||||
if (live.length === 0) {
|
||||
console.log('▸ Nothing to delete — no personas in mana-auth.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`▸ Found ${live.length} persona(s) to delete:`);
|
||||
for (const p of live) {
|
||||
const inCatalog = catalogEmails.has(p.email);
|
||||
console.log(` · ${p.email} (${p.archetype})${inCatalog ? '' : ' [⚠ not in catalog]'}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
const failures: string[] = [];
|
||||
for (const p of live) {
|
||||
try {
|
||||
await deletePersona(opts, p.userId, p.email);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(` ✗ ${p.email} — ${msg}`);
|
||||
failures.push(p.email);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (failures.length > 0) {
|
||||
console.error(`✗ Done with ${failures.length} failure(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`✓ Done. ${live.length} personas deleted.`);
|
||||
}
|
||||
|
||||
void main();
|
||||
31
scripts/personas/password.ts
Normal file
31
scripts/personas/password.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Deterministic per-persona password.
|
||||
*
|
||||
* HMAC-SHA256(PERSONA_SEED_SECRET, persona.email) → base64 → trim to 32
|
||||
* chars. Long enough for Better Auth's 8–128 range, identical every run
|
||||
* so seed/login are repeatable, but only knowable to whoever holds the
|
||||
* env secret.
|
||||
*
|
||||
* Why not random + storage: storing per-persona passwords means another
|
||||
* place to encrypt and audit. The runner needs to log in as the persona
|
||||
* many times — looking up a stored cred per call is wasteful when the
|
||||
* same HMAC gives us the same answer in O(1).
|
||||
*/
|
||||
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
const DEV_FALLBACK_SECRET = 'dev-persona-seed-secret-rotate-in-prod';
|
||||
|
||||
export function personaPassword(email: string): string {
|
||||
const secret = process.env.PERSONA_SEED_SECRET ?? DEV_FALLBACK_SECRET;
|
||||
if (secret === DEV_FALLBACK_SECRET && process.env.NODE_ENV === 'production') {
|
||||
throw new Error(
|
||||
'PERSONA_SEED_SECRET must be set in production — refusing to derive persona passwords from the dev fallback.'
|
||||
);
|
||||
}
|
||||
const hmac = createHmac('sha256', secret).update(email).digest('base64');
|
||||
// Strip non-alphanumerics so the result is safe in CLI quotes,
|
||||
// keep at least one digit + one letter for password-policy compliance.
|
||||
const cleaned = hmac.replace(/[^a-zA-Z0-9]/g, '');
|
||||
return cleaned.slice(0, 32);
|
||||
}
|
||||
112
scripts/personas/seed.ts
Normal file
112
scripts/personas/seed.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Seed the persona catalog into mana-auth.
|
||||
*
|
||||
* Idempotent: re-running upserts metadata, never duplicates users. New
|
||||
* personas in catalog.json get registered; existing ones get their
|
||||
* descriptor refreshed.
|
||||
*
|
||||
* Requires:
|
||||
* - mana-auth running (default http://localhost:3001)
|
||||
* - An admin-tier user JWT (export MANA_ADMIN_JWT or pass --jwt=…)
|
||||
* - PERSONA_SEED_SECRET in env (or accept the dev fallback locally)
|
||||
*
|
||||
* Usage:
|
||||
* pnpm seed:personas
|
||||
* pnpm seed:personas --auth=https://auth.mana.how --jwt=eyJ…
|
||||
*/
|
||||
|
||||
import { loadCatalog, type PersonaSpec } from './catalog';
|
||||
import { personaPassword } from './password';
|
||||
|
||||
interface CliOptions {
|
||||
authUrl: string;
|
||||
adminJwt: string;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(): CliOptions {
|
||||
const args = process.argv.slice(2);
|
||||
const get = (key: string): string | undefined => {
|
||||
const found = args.find((a) => a.startsWith(`--${key}=`));
|
||||
return found?.slice(`--${key}=`.length);
|
||||
};
|
||||
|
||||
const authUrl = get('auth') ?? process.env.MANA_AUTH_URL ?? 'http://localhost:3001';
|
||||
const adminJwt = get('jwt') ?? process.env.MANA_ADMIN_JWT ?? '';
|
||||
const dryRun = args.includes('--dry-run');
|
||||
|
||||
if (!adminJwt) {
|
||||
console.error(
|
||||
'❌ Missing admin JWT. Set MANA_ADMIN_JWT or pass --jwt=… (must be a token for a user with role=admin).'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { authUrl, adminJwt, dryRun };
|
||||
}
|
||||
|
||||
async function upsertPersona(opts: CliOptions, p: PersonaSpec): Promise<void> {
|
||||
const password = personaPassword(p.email);
|
||||
const body = {
|
||||
email: p.email,
|
||||
name: p.name,
|
||||
password,
|
||||
archetype: p.archetype,
|
||||
systemPrompt: p.systemPrompt,
|
||||
moduleMix: p.moduleMix,
|
||||
tickCadence: p.tickCadence,
|
||||
};
|
||||
|
||||
if (opts.dryRun) {
|
||||
console.log(` · would upsert ${p.email} (${p.archetype})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${opts.authUrl}/api/v1/admin/personas`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${opts.adminJwt}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '<unreadable body>');
|
||||
throw new Error(`POST /admin/personas → ${res.status}: ${text.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as { ok: true; userId: string; email: string };
|
||||
console.log(` ✓ ${result.email} (${p.archetype}) user=${result.userId.slice(0, 8)}…`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const opts = parseArgs();
|
||||
const { personas } = loadCatalog();
|
||||
|
||||
console.log(`▸ Persona catalog: ${personas.length} entries`);
|
||||
console.log(`▸ Auth URL: ${opts.authUrl}`);
|
||||
if (opts.dryRun) console.log('▸ DRY-RUN — no requests will be sent');
|
||||
console.log('');
|
||||
|
||||
const failures: Array<{ email: string; error: string }> = [];
|
||||
for (const persona of personas) {
|
||||
try {
|
||||
await upsertPersona(opts, persona);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(` ✗ ${persona.email} — ${msg}`);
|
||||
failures.push({ email: persona.email, error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (failures.length > 0) {
|
||||
console.error(`✗ Done with ${failures.length} failure(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`✓ Done. ${personas.length} personas upserted.`);
|
||||
}
|
||||
|
||||
void main();
|
||||
Loading…
Add table
Add a link
Reference in a new issue