From b4dd646fd79637424962caa24cc5561dc82e72c7 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 9 Apr 2026 11:55:26 +0200 Subject: [PATCH] feat(memoro): auto-generate voice memo titles via the LLM task queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real-world consumer of the @mana/shared-llm tier framework. After STT transcription completes for a voice memo, the memos store fire-and-forgets a generateTitleTask into the persistent task queue with refType:'memo' + refId:memoId. A module-side watcher subscribed via Dexie liveQuery to completed task rows writes the result back into memo.title and deletes the queue row to mark it consumed. What this commit ships: apps/mana/apps/web/src/lib/llm-tasks/generate-title.ts - generateTitleTask: minTier='none', contentClass='personal' - runLlm: sends a German system prompt asking for a 3-7 word title, defensive cleanup of any quotes/markdown the model might leak through despite the prompt - runRules: takes the first sentence (split on .!?\n), caps at maxWords/60-chars, returns a non-empty fallback string. Predictable and free, works on every device including the ones where the user has opted out of all LLM tiers. apps/mana/apps/web/src/lib/llm-task-registry.ts - Register generateTitleTask alongside extractDate + summarize so the queue processor can resolve the name back to the task object after a row is pulled from the persistent table. apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts - After transcribeMemo successfully writes the transcript + processingStatus:'completed', enqueue a generateTitleTask tagged with refType:'memo' + refId + priority:1. Skips the enqueue if the memo already has a non-empty title (so manually-titled memos aren't overwritten on re-transcription) or if the transcript came back empty. - Wrapped in try/catch — queue failures must NEVER break the transcription happy path. apps/mana/apps/web/src/lib/modules/memoro/llm-watcher.svelte.ts - startMemoroLlmWatcher() / stopMemoroLlmWatcher() - Subscribes via Dexie liveQuery to llmQueueDb.tasks rows where state='done', taskName='common.generateTitle', refType='memo'. For each row: 1. Skip + delete row if result isn't a string (defensive) 2. Skip + delete row if memo no longer exists (deleted between enqueue and result) 3. Skip + delete row if memo already has a manual title (user typed one during the LLM round-trip) 4. Otherwise: encryptRecord + memoTable.update with { title: result, updatedAt: now }, then delete the queue row to mark it consumed. - Module-scope subscription handle, idempotent start/stop. apps/mana/apps/web/src/routes/(app)/+layout.svelte - startMemoroLlmWatcher() in handleAuthReady's Phase A right after startLlmQueue(). The watcher needs to run regardless of whether the user is currently on /memoro — a memo transcribing in the background should auto-title even while the user is doing something else. - stopMemoroLlmWatcher() in onDestroy alongside stopLlmQueue(). End-to-end flow with a Tier 0 user (no AI enabled): 1. User records a memo via voice capture 2. memos.svelte.ts createWithTranscription() inserts the memo with processingStatus:'processing' 3. transcribeMemo POSTs the audio to mana-stt, awaits the transcript 4. Successful transcript → memos.svelte.ts writes { transcript, processingStatus:'completed' } to memoTable 5. Same function enqueues generateTitleTask with the transcript 6. LlmTaskQueue processor picks it up (the queue is running in the background since layout init), calls orchestrator.run(generateTitleTask, { text: transcript }) 7. Orchestrator: Tier 0 user → no LLM tier → falls through to runRules() which returns the first-sentence heuristic 8. Queue marks the row done with the rules-tier title string 9. Memoro watcher's liveQuery fires with the new completed row 10. Watcher writes title + deletes the queue row 11. ListView's existing useLiveQuery on memoTable picks up the title change automatically End-to-end flow with a Browser-tier user: Steps 1-6 identical, then: 7. Orchestrator: browser tier ready → calls generateTitleTask.runLlm with the BrowserBackend 8. Web Worker (Phase 3) runs Gemma 4 E2B against a 32-token budget, returns a 3-7 word German title 9-11. Same as Tier 0 — the title lands in memo.title without the user clicking anything This is the validation the entire 4-phase architecture was built for: a module-side auto-feature that's completely tier-agnostic, fire-and-forget, persistent across reloads, and that gracefully degrades from Gemma 4 down to a regex when the user has opted out. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/llm-task-registry.ts | 2 + .../web/src/lib/llm-tasks/generate-title.ts | 77 ++++++++++++++++ .../lib/modules/memoro/llm-watcher.svelte.ts | 90 +++++++++++++++++++ .../lib/modules/memoro/stores/memos.svelte.ts | 22 +++++ .../apps/web/src/routes/(app)/+layout.svelte | 11 +++ 5 files changed, 202 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/llm-tasks/generate-title.ts create mode 100644 apps/mana/apps/web/src/lib/modules/memoro/llm-watcher.svelte.ts diff --git a/apps/mana/apps/web/src/lib/llm-task-registry.ts b/apps/mana/apps/web/src/lib/llm-task-registry.ts index ebdb7d1ae..5dac77195 100644 --- a/apps/mana/apps/web/src/lib/llm-task-registry.ts +++ b/apps/mana/apps/web/src/lib/llm-task-registry.ts @@ -19,9 +19,11 @@ import type { TaskRegistry } from '@mana/shared-llm'; import { extractDateTask } from './llm-tasks/extract-date'; +import { generateTitleTask } from './llm-tasks/generate-title'; import { summarizeTextTask } from './llm-tasks/summarize'; export const taskRegistry: TaskRegistry = { [extractDateTask.name]: extractDateTask, + [generateTitleTask.name]: generateTitleTask, [summarizeTextTask.name]: summarizeTextTask, }; diff --git a/apps/mana/apps/web/src/lib/llm-tasks/generate-title.ts b/apps/mana/apps/web/src/lib/llm-tasks/generate-title.ts new file mode 100644 index 000000000..7d895a9a6 --- /dev/null +++ b/apps/mana/apps/web/src/lib/llm-tasks/generate-title.ts @@ -0,0 +1,77 @@ +/** + * generateTitleTask — produces a short title (3–7 words) for a longer + * piece of text. Used by memoro to auto-name voice memos after STT + * finishes, by notes for untitled drafts, by chat for thread names. + * + * Has a runRules() fallback so it works even on Tier 0: the fallback + * takes the first sentence (or first ~60 chars), strips trailing + * punctuation, and uses that as the title. It's not as nice as an + * LLM-generated title but it's predictable, free, and never empty. + */ + +import type { LlmBackend, LlmTask } from '@mana/shared-llm'; + +export interface GenerateTitleInput { + text: string; + /** Optional max title length in words. Default 7. */ + maxWords?: number; + /** Optional language hint for the system prompt. Default 'de'. */ + language?: string; +} + +export type GenerateTitleOutput = string; + +export const generateTitleTask: LlmTask = { + name: 'common.generateTitle', + minTier: 'none', // works on Tier 0 via the first-sentence heuristic + contentClass: 'personal', + displayLabel: 'Titel automatisch erzeugen', + + async runLlm(input, backend: LlmBackend): Promise { + const maxWords = input.maxWords ?? 7; + const language = input.language ?? 'de'; + const result = await backend.generate({ + taskName: generateTitleTask.name, + contentClass: generateTitleTask.contentClass, + messages: [ + { + role: 'system', + content: `Du erstellst kurze, aussagekräftige Titel (max. ${maxWords} Wörter) für Texte. Sprache: ${language}. Antworte AUSSCHLIESSLICH mit dem Titel — kein Markdown, keine Anführungszeichen, keine Vorrede, kein Punkt am Ende.`, + }, + { + role: 'user', + content: input.text.slice(0, 4000), // cap context for speed + }, + ], + temperature: 0.5, + maxTokens: 32, + }); + + // Defensive: strip surrounding quotes / markdown / trailing dots in + // case the model didn't fully respect the system prompt. + return result.content + .trim() + .replace(/^["'`*_]+|["'`*_]+$/g, '') + .replace(/\.+$/, '') + .trim(); + }, + + async runRules(input): Promise { + const text = input.text.trim(); + if (!text) return 'Ohne Titel'; + + // Take the first sentence — split on .!? or newline. + const firstSentence = text.split(/[.!?\n]/)[0]?.trim() ?? text; + + // Cap at ~60 chars / maxWords words, whichever comes first. + const maxWords = input.maxWords ?? 7; + const words = firstSentence.split(/\s+/).slice(0, maxWords); + let candidate = words.join(' '); + + if (candidate.length > 60) { + candidate = candidate.slice(0, 57).trimEnd() + '…'; + } + + return candidate || 'Ohne Titel'; + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/memoro/llm-watcher.svelte.ts b/apps/mana/apps/web/src/lib/modules/memoro/llm-watcher.svelte.ts new file mode 100644 index 000000000..3963e29cd --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/memoro/llm-watcher.svelte.ts @@ -0,0 +1,90 @@ +/** + * Memoro LLM result watcher. + * + * The persistent task queue stores LlmTask results in its own Dexie + * table — but for module-side data (like a memo's title), we want + * those results to land in the module's own collection so existing + * queries / UI keep working without per-component subscriptions. + * + * This file owns the bridge for memoro: it subscribes via Dexie + * liveQuery to completed `common.generateTitle` tasks tagged + * with refType: 'memo', and for each one writes the generated title + * back into the memo row, then deletes the queue entry to mark it + * consumed. Once consumed, the queue stays empty for that memo. + * + * The watcher is started exactly once per page session — see + * startMemoroLlmWatcher() below for the idempotent guard. The + * memoro module config calls it from its initialize() hook, but + * even if a future refactor calls it twice, the duplicate call is + * a no-op. + * + * Cleanup: the subscription handle is stored module-scope; the page + * teardown is implicit (page reload kills the dev server too). For + * a long-lived SPA we'd want stop() — punt that to a follow-up. + */ + +import { liveQuery, type Subscription } from 'dexie'; +import { llmQueueDb } from '$lib/llm-queue'; +import { encryptRecord } from '$lib/data/crypto'; +import { memoTable } from './collections'; +import type { LocalMemo } from './types'; + +let subscription: Subscription | null = null; + +export function startMemoroLlmWatcher(): void { + if (subscription) return; // already running + if (typeof window === 'undefined') return; // SSR-safe no-op + + const observable = liveQuery(async () => + llmQueueDb.tasks + .where('state') + .equals('done') + .and((t) => t.taskName === 'common.generateTitle' && t.refType === 'memo') + .toArray() + ); + + subscription = observable.subscribe({ + next: async (rows) => { + for (const row of rows) { + if (!row.refId || typeof row.result !== 'string') { + // Result shape didn't match — drop the queue row so we + // don't keep retrying it. + await llmQueueDb.tasks.delete(row.id); + continue; + } + + const memo = await memoTable.get(row.refId); + if (!memo) { + // Memo was deleted before the task finished — discard. + await llmQueueDb.tasks.delete(row.id); + continue; + } + + // Don't overwrite a manual title that the user typed + // between enqueue time and result time. + if (memo.title?.trim()) { + await llmQueueDb.tasks.delete(row.id); + continue; + } + + const diff: Partial = { + title: row.result, + updatedAt: new Date().toISOString(), + }; + await encryptRecord('memos', diff); + await memoTable.update(row.refId, diff); + + // Mark consumed + await llmQueueDb.tasks.delete(row.id); + } + }, + error: (err) => { + console.warn('[memoro-llm-watcher] subscription error:', err); + }, + }); +} + +export function stopMemoroLlmWatcher(): void { + subscription?.unsubscribe(); + subscription = null; +} diff --git a/apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts b/apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts index cc6aa3c37..011f30edf 100644 --- a/apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts @@ -10,6 +10,8 @@ import { toMemo } from '../queries'; import { createArchiveOps } from '@mana/shared-stores'; import { MemoroEvents } from '@mana/shared-utils/analytics'; import { encryptRecord } from '$lib/data/crypto'; +import { llmTaskQueue } from '$lib/llm-queue'; +import { generateTitleTask } from '$lib/llm-tasks/generate-title'; import type { LocalMemo } from '../types'; /** Archive/soft-delete ops for memos. */ @@ -106,6 +108,26 @@ export const memosStore = { }; await encryptRecord('memos', diff); await memoTable.update(memoId, diff); + + // Auto-title: if the user didn't already give the memo a title, + // queue a background task to generate one from the transcript. + // The task is fire-and-forget — the memoro LLM watcher + // (./llm-watcher.svelte.ts) picks up the result reactively and + // writes it back to memo.title. Works on every tier including + // none (regex-based first-sentence fallback). + if (!existing.title?.trim() && transcript.length > 0) { + try { + await llmTaskQueue.enqueue( + generateTitleTask, + { text: transcript, language: existing.language ?? result.language ?? 'de' }, + { refType: 'memo', refId: memoId, priority: 1 } + ); + } catch { + // Don't let queue failures break the transcription path. + // Worst case the memo stays untitled — the user can still + // rename it manually. + } + } } catch (e) { const msg = e instanceof Error ? e.message : String(e); await memoTable.update(memoId, { diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index 9adf8b370..198f2dabb 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -37,6 +37,10 @@ import { linkLocalStore, linkMutations } from '@mana/shared-links'; import { manaStore } from '$lib/data/local-store'; import { startLlmQueue, stopLlmQueue } from '$lib/llm-queue'; + import { + startMemoroLlmWatcher, + stopMemoroLlmWatcher, + } from '$lib/modules/memoro/llm-watcher.svelte'; import { createUnifiedSync } from '$lib/data/sync'; import { networkStore } from '$lib/stores/network.svelte'; import { db } from '$lib/data/database'; @@ -312,6 +316,12 @@ // from a crashed session) before going idle. See $lib/llm-queue.ts. startLlmQueue(); + // Module-side LLM result watchers. Each subscribes via Dexie + // liveQuery to completed task rows tagged for its module and + // writes the results back to the module's own collection + // (e.g. memoro auto-titles → memo.title). Idempotent. + startMemoroLlmWatcher(); + // Restore nav collapsed state if (typeof localStorage !== 'undefined') { const savedCollapsed = localStorage.getItem(STORAGE_KEYS.NAV_COLLAPSED); @@ -384,6 +394,7 @@ // will finish in the background and the next page session will // pick up where we left off. void stopLlmQueue(); + stopMemoroLlmWatcher(); }); // ── Search / Spotlight ───────────────────────────────────