feat(memoro): auto-generate voice memo titles via the LLM task queue

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 11:55:26 +02:00
parent 7a0959e519
commit b4dd646fd7
5 changed files with 202 additions and 0 deletions

View file

@ -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,
};

View file

@ -0,0 +1,77 @@
/**
* generateTitleTask produces a short title (37 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<GenerateTitleInput, GenerateTitleOutput> = {
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<GenerateTitleOutput> {
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<GenerateTitleOutput> {
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';
},
};

View file

@ -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<LocalMemo> = {
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;
}

View file

@ -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, {

View file

@ -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 ───────────────────────────────────