From 4966ca69f05b8c599684a33df6206eaf4a9954f2 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 23 Apr 2026 18:39:00 +0200 Subject: [PATCH] feat(tool-registry): add mood module (log/today/recent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third encrypted module in @mana/tool-registry, brings the registry to 16 tools across 7 modules. Lets the Anna / Sofia / Maya personas (whose moduleMix puts mood at 20–30 %) actually exercise their daily-tracking routine when the runner ticks. Three tools, all encrypted per the web-app registry (moodEntries: entry(['withWhom', 'notes'])): - mood.log Write a mood entry. `level` 1–10, `emotion` + `secondaryEmotions` from the taxonomy copied verbatim from apps/mana/.../modules/mood/ types.ts (keep in sync if new emotions/activities get added). date + time default to server-clock now; personas logging retrospectively pass them explicitly. - mood.today Return every entry for today (or `{ date }`) sorted by time. Multiple entries per day are normal — the web app timelines them. - mood.recent Last N days (default 7), newest first. Useful for self-reflection turns like "how has your week been?". Scope decisions Calendar was on the shortlist but dropped: `events` writes couple to `timeBlocks` (a separate table/appId), so one tool call becomes two sync pushes with a shared transaction concern — worth a careful session, not a drive-by. Goals dropped because `companionGoals` is owned by the Companion Brain, not a regular module, and has no clear mana-sync appId convention. Both candidates for a focused follow-up. Verified - `pnpm run validate:all` green (crypto registry 202/202, encrypted- tools audit 9/9 including the 3 new mood tools) - type-check across tool-registry + mcp + runner green - registerAllModules → 16 tools, 7 modules: habits: create/list/update/archive journal: add 🔐 me: listReferenceImages 🔐 / generateWithReference mood: log 🔐 / today 🔐 / recent 🔐 notes: create 🔐 / search 🔐 spaces: list todo: create 🔐 / list 🔐 / complete Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mana-tool-registry/src/modules/index.ts | 3 + .../mana-tool-registry/src/modules/mood.ts | 261 ++++++++++++++++++ packages/mana-tool-registry/src/types.ts | 1 + 3 files changed, 265 insertions(+) create mode 100644 packages/mana-tool-registry/src/modules/mood.ts diff --git a/packages/mana-tool-registry/src/modules/index.ts b/packages/mana-tool-registry/src/modules/index.ts index f5cc8a598..d37f959c6 100644 --- a/packages/mana-tool-registry/src/modules/index.ts +++ b/packages/mana-tool-registry/src/modules/index.ts @@ -13,6 +13,7 @@ import { registerHabitsTools } from './habits.ts'; import { registerJournalTools } from './journal.ts'; import { registerMeTools } from './me.ts'; +import { registerMoodTools } from './mood.ts'; import { registerNotesTools } from './notes.ts'; import { registerSpacesTools } from './spaces.ts'; import { registerTodoTools } from './todo.ts'; @@ -21,6 +22,7 @@ export function registerAllModules(): void { registerHabitsTools(); registerJournalTools(); registerMeTools(); + registerMoodTools(); registerNotesTools(); registerSpacesTools(); registerTodoTools(); @@ -30,6 +32,7 @@ export { registerHabitsTools, registerJournalTools, registerMeTools, + registerMoodTools, registerNotesTools, registerSpacesTools, registerTodoTools, diff --git a/packages/mana-tool-registry/src/modules/mood.ts b/packages/mana-tool-registry/src/modules/mood.ts new file mode 100644 index 000000000..f15160e04 --- /dev/null +++ b/packages/mana-tool-registry/src/modules/mood.ts @@ -0,0 +1,261 @@ +/** + * Mood — daily mood log entries (Stimmungstracking). + * + * Encrypted fields match the web-app registry entry for `moodEntries`: + * moodEntries: entry(['withWhom', 'notes']) + * + * Emotion + activity + date + level stay plaintext so the stats views + * (trends, cross-activity comparisons) can operate without a key-unlock + * round-trip. Free-text "with whom" and "notes" fields hold personal + * context that would embarrass the user if it leaked. + * + * Multiple entries per day supported — the web app renders them as a + * timeline, not a single-value daily picker. "Today" here means "all + * entries whose `date` is YYYY-MM-DD of server-clock today". + */ + +import { z } from 'zod'; +import { decryptRecordFields, encryptRecordFields } from '@mana/shared-crypto'; +import { pullAll, pushInsert } from '../sync-client.ts'; +import { registerTool } from '../registry.ts'; +import type { ToolContext, ToolSpec } from '../types.ts'; + +const APP_ID = 'mood'; +const TABLE = 'moodEntries'; +const ENCRYPTED_FIELDS = ['withWhom', 'notes'] as const; +const SYNC_URL = () => process.env.MANA_SYNC_URL ?? 'http://localhost:3050'; +const CLIENT_ID = () => process.env.MANA_MCP_CLIENT_ID ?? 'mana-mcp'; + +// Taxonomy copied verbatim from apps/mana/.../modules/mood/types.ts — +// keep in sync if the web app adds new emotions / activities. The +// CoreEmotion and ActivityContext unions are the authoritative source. +const EMOTIONS = [ + 'happy', + 'calm', + 'energized', + 'grateful', + 'excited', + 'loved', + 'hopeful', + 'neutral', + 'bored', + 'tired', + 'sad', + 'anxious', + 'angry', + 'stressed', + 'frustrated', + 'overwhelmed', +] as const; + +const ACTIVITIES = [ + 'work', + 'exercise', + 'social', + 'alone', + 'commute', + 'eating', + 'resting', + 'creative', + 'outdoors', + 'screen', + 'chores', + 'other', +] as const; + +// ─── Domain shape ───────────────────────────────────────────────── + +const entrySchema = z.object({ + id: z.string().uuid(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + time: z.string().regex(/^\d{2}:\d{2}$/), + level: z.number().int().min(1).max(10), + emotion: z.enum(EMOTIONS), + secondaryEmotions: z.array(z.enum(EMOTIONS)), + activity: z.enum(ACTIVITIES).nullable(), + withWhom: z.string(), + notes: z.string(), + tags: z.array(z.string()), + createdAt: z.string().datetime().optional(), + updatedAt: z.string().datetime().optional(), +}); + +type Entry = z.infer; +type EncryptedEntry = Record; + +function syncCfg(ctx: ToolContext) { + return { baseUrl: SYNC_URL(), jwt: ctx.jwt, clientId: CLIENT_ID() }; +} + +// ─── mood.log ───────────────────────────────────────────────────── + +const logInput = z.object({ + level: z.number().int().min(1).max(10), + emotion: z.enum(EMOTIONS), + secondaryEmotions: z.array(z.enum(EMOTIONS)).max(5).default([]), + activity: z.enum(ACTIVITIES).nullable().default(null), + withWhom: z.string().max(500).default(''), + notes: z.string().max(5000).default(''), + tags: z.array(z.string().max(60)).max(20).default([]), + /** + * ISO `YYYY-MM-DD` + `HH:mm`. Both default to server-clock now. + * Personas logging "yesterday's mood" pass these explicitly. + */ + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + time: z + .string() + .regex(/^\d{2}:\d{2}$/) + .optional(), +}); + +const logOutput = z.object({ entry: entrySchema }); + +export const moodLog: ToolSpec = { + name: 'mood.log', + module: 'mood', + scope: 'user-space', + policyHint: 'write', + description: + 'Log a mood entry. `level` is 1–10 (1 = worst, 10 = best). `emotion` is the primary feeling; up to 5 `secondaryEmotions` can nuance it. `withWhom` and `notes` are encrypted before storage.', + input: logInput, + output: logOutput, + encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS }, + async handler(input, ctx) { + const key = await ctx.getMasterKey(); + const now = new Date(); + + const plaintext: Entry = { + id: crypto.randomUUID(), + date: input.date ?? now.toISOString().slice(0, 10), + time: input.time ?? now.toISOString().slice(11, 16), + level: input.level, + emotion: input.emotion, + secondaryEmotions: input.secondaryEmotions, + activity: input.activity, + withWhom: input.withWhom, + notes: input.notes, + tags: input.tags, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + + const encrypted = await encryptRecordFields( + plaintext as unknown as Record, + ENCRYPTED_FIELDS, + key + ); + + await pushInsert(syncCfg(ctx), APP_ID, { + table: TABLE, + id: plaintext.id, + spaceId: ctx.spaceId, + data: encrypted, + }); + + ctx.logger.info('mood.log', { + entryId: plaintext.id, + date: plaintext.date, + level: plaintext.level, + emotion: plaintext.emotion, + }); + return { entry: plaintext }; + }, +}; + +// ─── mood.today ─────────────────────────────────────────────────── + +const todayInput = z.object({ + /** + * Optional override for the server's "today" (YYYY-MM-DD). Lets the + * runner simulate retrospective analysis ("how was I last Tuesday?") + * without an extra tool. + */ + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), +}); + +const listOutput = z.object({ entries: z.array(entrySchema) }); + +export const moodToday: ToolSpec = { + name: 'mood.today', + module: 'mood', + scope: 'user-space', + policyHint: 'read', + description: + 'List every mood entry for today (or the given date). Multiple entries per day are normal — the web app timelines them. Entries returned decrypted.', + input: todayInput, + output: listOutput, + encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS }, + async handler(input, ctx) { + const key = await ctx.getMasterKey(); + const target = input.date ?? new Date().toISOString().slice(0, 10); + + const res = await pullAll(syncCfg(ctx), APP_ID, TABLE); + const alive = res.changes.filter((c) => c.op !== 'delete' && c.data).map((c) => c.data!); + + const decrypted = (await Promise.all( + alive.map((row) => decryptRecordFields(row, ENCRYPTED_FIELDS, key)) + )) as unknown as Entry[]; + + const entries = decrypted + .filter((e) => e.date === target) + .sort((a, b) => a.time.localeCompare(b.time)); + + return { entries }; + }, +}; + +// ─── mood.recent ────────────────────────────────────────────────── + +const recentInput = z.object({ + days: z.number().int().min(1).max(90).default(7), + limit: z.number().int().min(1).max(200).default(50), +}); + +export const moodRecent: ToolSpec = { + name: 'mood.recent', + module: 'mood', + scope: 'user-space', + policyHint: 'read', + description: + 'Return mood entries from the last `days` days (default 7), newest first, capped at `limit`. Useful for "how has my week been?" reflection. Entries decrypted.', + input: recentInput, + output: listOutput, + encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS }, + async handler(input, ctx) { + const key = await ctx.getMasterKey(); + const cutoff = new Date(Date.now() - input.days * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + + const res = await pullAll(syncCfg(ctx), APP_ID, TABLE); + const alive = res.changes.filter((c) => c.op !== 'delete' && c.data).map((c) => c.data!); + + const decrypted = (await Promise.all( + alive.map((row) => decryptRecordFields(row, ENCRYPTED_FIELDS, key)) + )) as unknown as Entry[]; + + const entries = decrypted + .filter((e) => e.date >= cutoff) + .sort((a, b) => { + if (a.date !== b.date) return b.date.localeCompare(a.date); + return b.time.localeCompare(a.time); + }) + .slice(0, input.limit); + + return { entries }; + }, +}; + +// ─── Registration barrel ────────────────────────────────────────── + +export function registerMoodTools(): void { + registerTool(moodLog); + registerTool(moodToday); + registerTool(moodRecent); +} diff --git a/packages/mana-tool-registry/src/types.ts b/packages/mana-tool-registry/src/types.ts index 13cda09ec..a36b50029 100644 --- a/packages/mana-tool-registry/src/types.ts +++ b/packages/mana-tool-registry/src/types.ts @@ -27,6 +27,7 @@ export type ModuleId = | 'articles' | 'missions' | 'tags' + | 'mood' // — M5 (me-images + reference-based image generation) — | 'me';