From 8fbdc6db772b68ddcd865325c44d102fbacd5435 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 29 Apr 2026 00:06:34 +0200 Subject: [PATCH] feat(notes): isSpaceContext flag replaces kontext module (Option B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retire the kontext module entirely; the per-Space standing-context document is now a regular Note flagged with `isSpaceContext: true`. Daily use ("URL → Notiz") moves to the notes module as a first-class action; the same primitive is reused by the (planned) Brand/Firma-Space onboarding wizard to seed a Space-context Note from a URL. Why: kontext was inconsistent — its UI was a URL-crawler that wrote to userContext.freeform (profile module), while its kontextDoc table + AI-Mission-Runner auto-injection was a write-only shell with no real editor. One concept (Notes) now carries both ad-hoc noting and Space-context, with mutex (max 1 flagged Note per Space). Notes module: - types: add `isSpaceContext?: boolean` to LocalNote + Note - queries: add `useSpaceContextNote()` (the active Space's flagged note) - store: `markAsSpaceContext(id | null)` with mutex sweep across Space - ListView: "Aus URL importieren" inline form (URL + crawl-mode + KI-Zusammenfassung toggle); "Als Space-Kontext markieren" / "Space-Kontext lösen" context-menu item; ★-Badge on flagged notes - new api.ts: `crawlUrl()` client for POST /api/v1/notes/import-url Notes API (apps/api): - new modules/notes/routes.ts with /import-url (ported from kontext; same crawler + LLM summary pipeline, NOTES_IMPORT_URL credit op) - mount at /api/v1/notes; add 'notes' to RESOURCE_MODULES (beta+ tier) - delete modules/context (UI-less /ai/generate + /ai/estimate had no consumers; /import-url moved to notes) - packages/credits: rename AI_CONTEXT_GENERATION → NOTES_IMPORT_URL AI Mission Runner: - default-resolvers: drop kontextResolver + kontextIndexer; the notesIndexer flags `isSpaceContext` notes with "★ " prefix and bubbles them to the top of the picker - writing reference-resolver: `kind: 'kontext'` now reads the flagged Note via scope-scan instead of the kontextDoc table; tests updated - writing ReferencePicker: useSpaceContextNote replaces useKontextDoc - AiDebugBlock + MissionGrantDialog + ai-missions ListView: drop 'kontextDoc' from ENCRYPTED_SERVER_TABLES set - ai-agents ListView: drop 'kontext' from POLICY_MODULES Profile module: - ContextFreeform.svelte: switch import from kontext/api to notes/api (the URL-crawl is the same primitive; it still writes to userContext.freeform — only the import path changed) Dexie: - v58: notes index gains `isSpaceContext`; kontextDoc table dropped Kontext module deletion: - delete apps/mana/apps/web/src/lib/modules/kontext/ entirely - delete (app)/kontext/ route - drop registerApp + Scroll icon from app-registry/apps.ts - drop kontext entry from help-content - drop kontextModuleConfig from data/module-registry.ts - drop kontextDoc from crypto registry mana-auth: - bootstrap-singletons: drop bootstrapSpaceSingletons function entirely (kontextDoc was the only per-Space singleton); userContext bootstrap unchanged - better-auth.config: drop kontextDoc bootstrap call from personal-space hook + organizationHooks.afterCreateOrganization - me-bootstrap: drop per-space bootstrap loop; response shape kept (always-empty `spaces: {}`) for backwards-compat with older clients Note: the still-existing legacy `context` module (CMS-style docs/spaces, unrelated to kontext) is left in place; its cleanup landed on the articles-bulk-import branch and is out of scope for this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/index.ts | 6 +- .../src/modules/{context => notes}/routes.ts | 95 +---- .../apps/web/src/lib/app-registry/apps.ts | 11 - .../web/src/lib/app-registry/help-content.ts | 12 - .../components/ai/MissionGrantDialog.svelte | 8 +- .../lib/data/ai/missions/default-resolvers.ts | 82 ++-- .../web/src/lib/data/bootstrap-singletons.ts | 10 +- .../apps/web/src/lib/data/crypto/registry.ts | 8 +- apps/mana/apps/web/src/lib/data/database.ts | 12 + .../apps/web/src/lib/data/module-registry.ts | 2 - apps/mana/apps/web/src/lib/data/sync.test.ts | 4 +- .../src/lib/modules/ai-agents/ListView.svelte | 2 +- .../lib/modules/ai-missions/ListView.svelte | 8 +- .../lib/modules/kontext/KontextView.svelte | 360 ------------------ .../src/lib/modules/kontext/collections.ts | 8 - .../apps/web/src/lib/modules/kontext/index.ts | 9 - .../src/lib/modules/kontext/module.config.ts | 6 - .../web/src/lib/modules/kontext/queries.ts | 41 -- .../modules/kontext/stores/kontext.svelte.ts | 77 ---- .../apps/web/src/lib/modules/kontext/types.ts | 30 -- .../web/src/lib/modules/notes/ListView.svelte | 248 +++++++++++- .../src/lib/modules/{kontext => notes}/api.ts | 12 +- .../apps/web/src/lib/modules/notes/queries.ts | 20 + .../lib/modules/notes/stores/notes.svelte.ts | 37 ++ .../apps/web/src/lib/modules/notes/types.ts | 10 + .../modules/profile/ContextFreeform.svelte | 4 +- .../writing/components/ReferencePicker.svelte | 13 +- .../writing/utils/reference-resolver.test.ts | 51 ++- .../writing/utils/reference-resolver.ts | 26 +- .../web/src/routes/(app)/kontext/+page.svelte | 12 - packages/credits/src/operations.ts | 16 +- packages/shared-ai/src/actor.ts | 6 +- packages/shared-utils/src/analytics.ts | 13 - .../mana-auth/src/auth/better-auth.config.ts | 53 +-- services/mana-auth/src/index.ts | 8 +- services/mana-auth/src/routes/me-bootstrap.ts | 62 +-- .../src/services/bootstrap-singletons.ts | 97 +---- 37 files changed, 496 insertions(+), 983 deletions(-) rename apps/api/src/modules/{context => notes}/routes.ts (64%) delete mode 100644 apps/mana/apps/web/src/lib/modules/kontext/KontextView.svelte delete mode 100644 apps/mana/apps/web/src/lib/modules/kontext/collections.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/kontext/index.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/kontext/module.config.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/kontext/queries.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/kontext/stores/kontext.svelte.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/kontext/types.ts rename apps/mana/apps/web/src/lib/modules/{kontext => notes}/api.ts (63%) delete mode 100644 apps/mana/apps/web/src/routes/(app)/kontext/+page.svelte diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8aea814f7..35aaa2cfe 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -28,7 +28,7 @@ import { calendarRoutes } from './modules/calendar/routes'; import { contactsRoutes } from './modules/contacts/routes'; import { musicRoutes } from './modules/music/routes'; import { chatRoutes } from './modules/chat/routes'; -import { contextRoutes } from './modules/context/routes'; +import { notesRoutes } from './modules/notes/routes'; import { pictureRoutes } from './modules/picture/routes'; import { profileRoutes } from './modules/profile/routes'; import { wardrobeRoutes } from './modules/wardrobe/routes'; @@ -94,10 +94,10 @@ app.use('/api/*', authMiddleware()); // their own records. const RESOURCE_MODULES = [ 'chat', - 'context', 'food', 'guides', 'news-research', + 'notes', 'picture', 'plants', 'research', @@ -121,7 +121,7 @@ app.route('/api/v1/calendar', calendarRoutes); app.route('/api/v1/contacts', contactsRoutes); app.route('/api/v1/music', musicRoutes); app.route('/api/v1/chat', chatRoutes); -app.route('/api/v1/context', contextRoutes); +app.route('/api/v1/notes', notesRoutes); app.route('/api/v1/picture', pictureRoutes); app.route('/api/v1/profile', profileRoutes); app.route('/api/v1/wardrobe', wardrobeRoutes); diff --git a/apps/api/src/modules/context/routes.ts b/apps/api/src/modules/notes/routes.ts similarity index 64% rename from apps/api/src/modules/context/routes.ts rename to apps/api/src/modules/notes/routes.ts index 875d7ac06..d9a6bfea2 100644 --- a/apps/api/src/modules/context/routes.ts +++ b/apps/api/src/modules/notes/routes.ts @@ -1,8 +1,11 @@ /** - * Context module — AI text generation + token estimation - * Ported from apps/context/apps/server + * Notes module — server-side helpers. * - * CRUD for spaces/documents handled by mana-sync. + * Today: a single `POST /import-url` endpoint that crawls a URL via + * mana-crawler and optionally summarises the result with mana-llm. The + * client treats the response as the body of a new Note (title + + * markdown content). The same endpoint is reused by the (planned) + * Brand/Firma-Space onboarding wizard to seed the Space-context note. */ import { Hono } from 'hono'; @@ -16,8 +19,6 @@ const DEFAULT_SUMMARY_MODEL = MANA_LLM.FAST_TEXT; const routes = new Hono<{ Variables: AuthVariables }>(); -// ─── URL Import (crawler → optional LLM summary → document) ── - const DEEP_MAX_PAGES = 20; const CRAWL_POLL_INTERVAL_MS = 1500; const CRAWL_TIMEOUT_MS = 90_000; @@ -25,20 +26,16 @@ const CRAWL_TIMEOUT_MS = 90_000; /** * Local LLMs love to wrap Markdown in ```markdown fences or prepend * a "Hier ist die Zusammenfassung:" preamble. Strip those so the - * output renders correctly when dropped into the Kontext document. + * output renders correctly when dropped into a Note body. */ function sanitizeSummary(raw: string): string { let s = raw.trim(); - // Strip a leading ```markdown / ```md / ``` fence and its closing ```. const fenceMatch = s.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n?```\s*$/i); if (fenceMatch) s = fenceMatch[1].trim(); - // Drop a single-line preamble that ends with a colon (LLM chatter). const lines = s.split('\n'); if (lines.length > 2 && /^[^#\n].{0,80}:\s*$/.test(lines[0].trim())) { s = lines.slice(1).join('\n').trim(); } - // Demote a solitary leading H1 to H2 so it doesn't clash with our - // section header that the frontend prepends. s = s.replace(/^#\s+/, '## '); return s; } @@ -73,7 +70,7 @@ routes.post('/import-url', async (c) => { } const creditCost = summarize ? 5 : 1; - const validation = await validateCredits(userId, 'AI_CONTEXT_IMPORT_URL', creditCost); + const validation = await validateCredits(userId, 'NOTES_IMPORT_URL', creditCost); if (!validation.hasCredits) { return c.json( { @@ -147,7 +144,7 @@ routes.post('/import-url', async (c) => { { role: 'system', content: - 'Du bist ein Assistent, der Web-Inhalte in strukturierte Kontext-Dokumente zusammenfasst. ' + + 'Du bist ein Assistent, der Web-Inhalte in strukturierte Notiz-Dokumente zusammenfasst. ' + 'Antworte ausschließlich in sauberem Markdown. Gliedere in H2-Abschnitte: ' + '"## Überblick", "## Kernaussagen", "## Details". Nutze die Sprache der Quelle. ' + 'Schreibe die Antwort direkt, ohne Einleitung ("Hier ist…"), ohne Schlussformel, ' + @@ -175,7 +172,7 @@ routes.post('/import-url', async (c) => { await consumeCredits( userId, - 'AI_CONTEXT_IMPORT_URL', + 'NOTES_IMPORT_URL', creditCost, `URL import (${mode}${summarize ? ' + summary' : ''})` ); @@ -194,74 +191,4 @@ routes.post('/import-url', async (c) => { } }); -// ─── AI Generation (server-only: mana-llm) ────────────────── - -routes.post('/ai/generate', async (c) => { - const userId = c.get('userId'); - const { prompt, documents, model, maxTokens } = await c.req.json(); - - if (!prompt) return c.json({ error: 'prompt required' }, 400); - - // Validate credits - const validation = await validateCredits(userId, 'AI_CONTEXT_GENERATE', 5); - if (!validation.hasCredits) { - return c.json( - { error: 'Insufficient credits', required: 5, available: validation.availableCredits }, - 402 - ); - } - - try { - // Build messages with document context - const messages: Array<{ role: string; content: string }> = []; - - if (documents?.length) { - const contextText = documents - .map((d: { title: string; content: string }) => `--- ${d.title} ---\n${d.content}`) - .join('\n\n'); - messages.push({ - role: 'system', - content: `Verwende diese Dokumente als Kontext:\n\n${contextText}`, - }); - } - - messages.push({ role: 'user', content: prompt }); - - const res = await fetch(`${LLM_URL}/api/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages, - model: model || MANA_LLM.FAST_TEXT, - max_tokens: maxTokens || 2000, - }), - }); - - if (!res.ok) return c.json({ error: 'AI generation failed' }, 502); - - const data = await res.json(); - const content = data.choices?.[0]?.message?.content || ''; - const tokensUsed = data.usage?.total_tokens || 0; - - // Consume credits - await consumeCredits(userId, 'AI_CONTEXT_GENERATE', 5, `AI generation (${tokensUsed} tokens)`); - - return c.json({ content, tokensUsed, model: model || MANA_LLM.FAST_TEXT }); - } catch (_err) { - return c.json({ error: 'Generation failed' }, 500); - } -}); - -routes.post('/ai/estimate', async (c) => { - const { prompt, documents } = await c.req.json(); - const charCount = - (prompt?.length || 0) + - (documents || []).reduce( - (sum: number, d: { content: string }) => sum + (d.content?.length || 0), - 0 - ); - const estimatedTokens = Math.ceil(charCount / 4); - return c.json({ estimatedTokens, estimatedCost: 5 }); -}); - -export { routes as contextRoutes }; +export { routes as notesRoutes }; diff --git a/apps/mana/apps/web/src/lib/app-registry/apps.ts b/apps/mana/apps/web/src/lib/app-registry/apps.ts index 43ccd6a0b..f35b87676 100644 --- a/apps/mana/apps/web/src/lib/app-registry/apps.ts +++ b/apps/mana/apps/web/src/lib/app-registry/apps.ts @@ -68,7 +68,6 @@ import { Question, ChatCircleDots, SquaresFour, - Scroll, Spiral, Crown, ShootingStar, @@ -576,16 +575,6 @@ registerApp({ paramKey: 'conversationId', }); -registerApp({ - id: 'kontext', - name: 'Web-Context', - color: '#A78B6F', - icon: Scroll, - views: { - list: { load: () => import('$lib/modules/kontext/KontextView.svelte') }, - }, -}); - registerApp({ id: 'times', name: 'Times', diff --git a/apps/mana/apps/web/src/lib/app-registry/help-content.ts b/apps/mana/apps/web/src/lib/app-registry/help-content.ts index 5c876148d..a3b76cbfc 100644 --- a/apps/mana/apps/web/src/lib/app-registry/help-content.ts +++ b/apps/mana/apps/web/src/lib/app-registry/help-content.ts @@ -175,18 +175,6 @@ export const MODULE_HELP: Record = { 'Nutze Vorlagen für wiederkehrende Aufgaben', ], }, - kontext: { - description: 'Persönliches Markdown-Dokument das der AI als Hintergrundwissen mitgegeben wird.', - features: [ - 'Freitext-Markdown', - 'Wird automatisch in AI-Missionen als Kontext injiziert', - 'Pro Agent individuell konfigurierbar', - ], - tips: [ - 'Schreibe hier Dinge die die AI über dich wissen sollte: Vorlieben, Arbeitsweise, Projekte', - 'Jeder Agent kann ein eigenes Kontext-Dokument haben', - ], - }, context: { description: 'Strukturiertes Profil — Interessen, Tagesablauf, Ziele, Ernährung. Hilft der AI dich besser zu verstehen.', diff --git a/apps/mana/apps/web/src/lib/components/ai/MissionGrantDialog.svelte b/apps/mana/apps/web/src/lib/components/ai/MissionGrantDialog.svelte index 02a019baf..4e5116ada 100644 --- a/apps/mana/apps/web/src/lib/components/ai/MissionGrantDialog.svelte +++ b/apps/mana/apps/web/src/lib/components/ai/MissionGrantDialog.svelte @@ -36,13 +36,7 @@ * sync with `services/mana-ai/src/db/resolvers/index.ts`. A mission * referencing any of these tables triggers the dialog. */ - const ENCRYPTED_SERVER_TABLES = new Set([ - 'notes', - 'tasks', - 'events', - 'journalEntries', - 'kontextDoc', - ]); + const ENCRYPTED_SERVER_TABLES = new Set(['notes', 'tasks', 'events', 'journalEntries']); interface Props { /** Mission to issue the grant for. Required — the dialog reads diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts b/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts index 8ef182908..d3dbb1aa5 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts @@ -2,14 +2,19 @@ * Default input resolvers. * * Registered from `setup.ts` so the production MissionRunner can load - * notes / kontext / goals without every module having to know about the - * AI subsystem. Modules that need special projection logic register their - * own resolver on init and override these defaults. + * notes / goals / profile / todo / calendar without every module having + * to know about the AI subsystem. Modules that need special projection + * logic register their own resolver on init and override these defaults. + * + * Space-Kontext: since the kontextDoc table was retired in favour of a + * `notes.isSpaceContext` flag, the "this is the standing context for + * this Space" candidate is just a regular Note marked with the flag. + * The notesResolver handles it like any other note; the notesIndexer + * surfaces it with a star prefix so the picker bubbles it to the top. */ import { db } from '../../database'; import { decryptRecords } from '../../crypto'; -import { scopedTable } from '../../scope/scoped-db'; import { registerInputResolver } from './input-resolvers'; import { registerInputIndexer } from './input-index'; import type { InputResolver } from './input-resolvers'; @@ -20,6 +25,7 @@ interface NoteLike { title?: string; content?: string; deletedAt?: string; + isSpaceContext?: boolean; } const notesResolver: InputResolver = async (ref) => { @@ -35,24 +41,6 @@ const notesResolver: InputResolver = async (ref) => { }; }; -interface KontextDocLike { - id: string; - content?: string; -} - -const kontextResolver: InputResolver = async (ref) => { - const doc = await db.table('kontextDoc').get(ref.id); - if (!doc) return null; - const [decrypted] = await decryptRecords('kontextDoc', [doc]); - return { - id: ref.id, - module: ref.module, - table: ref.table, - title: 'Kontext', - content: decrypted.content ?? '', - }; -}; - // ── User Context (structured profile + freeform) ────────── interface UserContextLike { @@ -155,35 +143,25 @@ const notesIndexer: InputIndexer = async () => { const all = await db.table('notes').toArray(); const visible = all.filter((n) => !n.deletedAt); const decrypted = await decryptRecords('notes', visible); - return decrypted - .map((n) => ({ - module: 'notes', - table: 'notes', - id: n.id, - label: (n.title && n.title.trim()) || '(ohne Titel)', - hint: n.content ? `${n.content.slice(0, 60).replace(/\s+/g, ' ')}…` : undefined, - })) - .slice(0, 200); // cap — Mission picker isn't meant to list thousands -}; - -const kontextIndexer: InputIndexer = async () => { - // Per-Space since Phase 2d.2: the kontextDoc for the active Space is - // the only candidate we surface to the picker. Personal-Space's legacy - // singleton row is matched via the `_personal:` sentinel in - // scopedTable's getInScopeSpaceIds(); Shared/Brand/Family Spaces that - // haven't yet authored a kontextDoc simply return an empty list. - const rows = await scopedTable('kontextDoc').toArray(); - const match = rows[0]; - if (!match) return []; - return [ - { - module: 'kontext', - table: 'kontextDoc', - id: match.id, - label: 'Kontext-Dokument', - hint: 'Dein zentrales Markdown-Dokument für diesen Space', - }, - ]; + const candidates = decrypted.map((n) => ({ + module: 'notes', + table: 'notes', + id: n.id, + label: (n.isSpaceContext ? '★ ' : '') + ((n.title && n.title.trim()) || '(ohne Titel)'), + hint: n.isSpaceContext + ? 'Space-Kontext (auto-injected)' + : n.content + ? `${n.content.slice(0, 60).replace(/\s+/g, ' ')}…` + : undefined, + })); + // Sort: space-context-flagged notes first, then alphabetical. + candidates.sort((a, b) => { + const aFirst = a.label.startsWith('★ '); + const bFirst = b.label.startsWith('★ '); + if (aFirst !== bFirst) return aFirst ? -1 : 1; + return a.label.localeCompare(b.label); + }); + return candidates.slice(0, 200); // cap — Mission picker isn't meant to list thousands }; const goalsIndexer: InputIndexer = async () => { @@ -303,13 +281,11 @@ let registered = false; export function registerDefaultInputResolvers(): void { if (registered) return; registerInputResolver('notes', notesResolver); - registerInputResolver('kontext', kontextResolver); registerInputResolver('profile', userContextResolver); registerInputResolver('goals', goalsResolver); registerInputResolver('todo', tasksResolver); registerInputResolver('calendar', calendarResolver); registerInputIndexer('notes', notesIndexer); - registerInputIndexer('kontext', kontextIndexer); registerInputIndexer('profile', userContextIndexer); registerInputIndexer('goals', goalsIndexer); registerInputIndexer('todo', tasksIndexer); diff --git a/apps/mana/apps/web/src/lib/data/bootstrap-singletons.ts b/apps/mana/apps/web/src/lib/data/bootstrap-singletons.ts index eb501a94f..c9338812a 100644 --- a/apps/mana/apps/web/src/lib/data/bootstrap-singletons.ts +++ b/apps/mana/apps/web/src/lib/data/bootstrap-singletons.ts @@ -3,8 +3,8 @@ * * Calls `POST /api/v1/me/bootstrap-singletons` on every authenticated * boot. The server-side endpoint provisions any missing per-user - * (`userContext`) and per-Space (`kontextDoc`) singletons in - * `mana_sync.sync_changes`. Idempotent — a second call is a no-op. + * `userContext` singleton in `mana_sync.sync_changes`. Idempotent — a + * second call is a no-op. * * Why call it on boot when the signup-time hooks already do this work: * the hooks are fire-and-forget and a transient mana_sync outage during @@ -14,9 +14,9 @@ * the first write. * * Best-effort: failures are swallowed and logged. The webapp's - * fallback paths (`getOrCreateLocalDoc()` in `userContextStore` / - * `kontextStore`) still cover the rare race where a write happens - * before the bootstrap row arrives. + * fallback path (`getOrCreateLocalDoc()` in `userContextStore`) still + * covers the rare race where a write happens before the bootstrap row + * arrives. */ import { browser } from '$app/environment'; diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 1e5992c37..5d03592fa 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -559,9 +559,6 @@ export const ENCRYPTION_REGISTRY: Record = { moodEntries: { enabled: true, fields: ['withWhom', 'notes'] }, moodSettings: { enabled: false, fields: [] }, - // ─── Kontext (legacy — now web-context, URL-crawl only) ── - kontextDoc: { enabled: true, fields: ['content'] }, - // ─── User Context (profile hub) ────────────────────────── // Structured profile sections + freeform markdown. Everything // except the fixed id and interview progress is user-typed content. @@ -690,8 +687,9 @@ export const ENCRYPTION_REGISTRY: Record = { 'livingOracleSnapshot', ]), - // Per-agent kontext documents — same schema as kontextDoc but keyed - // per agent. Content is free-form markdown. + // Per-agent kontext documents — free-form markdown, keyed per agent. + // Distinct from the (retired) per-Space kontextDoc; this one stays + // because per-agent context is still injected by the persona-runner. agentKontextDocs: { enabled: true, fields: ['content'] }, // ─── Quiz ──────────────────────────────────────────────── diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index e116327f2..73bb07060 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -1445,6 +1445,18 @@ db.version(57).stores({ formResponses: 'id, formId, status, submittedAt, _updatedAtIndex, [formId+status]', }); +// v58 — Replace the per-Space `kontextDoc` singleton with a flagged +// Note. The `notes` table gets an `isSpaceContext` index so the AI +// Mission Runner's resolver can find the flagged row without scanning +// every note; the kontextDoc table is dropped entirely (the kontext +// module's UI was a write-only shell, the table was never edit-able +// from a real surface). Mutex (max 1 flagged note per Space) is +// enforced by `notesStore.markAsSpaceContext`, not by Dexie. +db.version(58).stores({ + notes: 'id, isPinned, isArchived, isSpaceContext, color, title, _updatedAtIndex', + kontextDoc: null, +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index c8a681086..8721b4fd6 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -94,7 +94,6 @@ import { mailModuleConfig } from '$lib/modules/mail/module.config'; import { meditateModuleConfig } from '$lib/modules/meditate/module.config'; import { sleepModuleConfig } from '$lib/modules/sleep/module.config'; import { moodModuleConfig } from '$lib/modules/mood/module.config'; -import { kontextModuleConfig } from '$lib/modules/kontext/module.config'; import { quizModuleConfig } from '$lib/modules/quiz/module.config'; import { profileModuleConfig } from '$lib/modules/profile/module.config'; import { libraryModuleConfig } from '$lib/modules/library/module.config'; @@ -158,7 +157,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ meditateModuleConfig, sleepModuleConfig, moodModuleConfig, - kontextModuleConfig, quizModuleConfig, profileModuleConfig, libraryModuleConfig, diff --git a/apps/mana/apps/web/src/lib/data/sync.test.ts b/apps/mana/apps/web/src/lib/data/sync.test.ts index e43033c5b..3629f7f2d 100644 --- a/apps/mana/apps/web/src/lib/data/sync.test.ts +++ b/apps/mana/apps/web/src/lib/data/sync.test.ts @@ -763,8 +763,8 @@ describe('applyServerChanges (Dexie integration)', () => { it('bootstrap-twin race: local SYSTEM_BOOTSTRAP row + later server insert → no conflict, LWW wins', async () => { // 1. Client-side fallback creates an empty row stamped origin='system'. - // This is what `getOrCreateLocalDoc()` does in userContextStore / - // kontextStore when a write lands before the first sync pull. + // This is what `getOrCreateLocalDoc()` does in userContextStore + // when a write lands before the first sync pull. const bootstrapActor = makeSystemActor(SYSTEM_BOOTSTRAP); await runAsAsync(bootstrapActor, async () => { await db.table('tasks').add({ diff --git a/apps/mana/apps/web/src/lib/modules/ai-agents/ListView.svelte b/apps/mana/apps/web/src/lib/modules/ai-agents/ListView.svelte index f8cc21fd0..e9a6b4932 100644 --- a/apps/mana/apps/web/src/lib/modules/ai-agents/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/ai-agents/ListView.svelte @@ -149,7 +149,7 @@ } // ── Policy editor ─────────────────────────────────────── - const POLICY_MODULES = ['todo', 'calendar', 'notes', 'kontext', 'finance', 'drink', 'food']; + const POLICY_MODULES = ['todo', 'calendar', 'notes', 'finance', 'drink', 'food']; const POLICY_CHOICES: PolicyDecision[] = ['auto', 'propose', 'deny']; function policyLabel(c: PolicyDecision): string { return $_('ai-agents.list_view.policy_label_' + c); diff --git a/apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte b/apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte index e3956d82c..907d89a4c 100644 --- a/apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte @@ -113,13 +113,7 @@ } // ── Key-Grant (server-side execution) ────────────────── - const ENCRYPTED_SERVER_TABLES = new Set([ - 'notes', - 'tasks', - 'events', - 'journalEntries', - 'kontextDoc', - ]); + const ENCRYPTED_SERVER_TABLES = new Set(['notes', 'tasks', 'events', 'journalEntries']); function hasEncryptedInputs(m: Mission): boolean { return m.inputs.some((i) => ENCRYPTED_SERVER_TABLES.has(i.table)); } diff --git a/apps/mana/apps/web/src/lib/modules/kontext/KontextView.svelte b/apps/mana/apps/web/src/lib/modules/kontext/KontextView.svelte deleted file mode 100644 index 3db69fe4e..000000000 --- a/apps/mana/apps/web/src/lib/modules/kontext/KontextView.svelte +++ /dev/null @@ -1,360 +0,0 @@ - - - -
-
-
- -
-
-

Web-Context

-

Crawle Webseiten und speichere den Inhalt in deinem Profil-Kontext

-
-
- -
-
- - -
- -
- - - · - -
- - {#if importing || importPhase !== 'idle'} -
    - {#each importPhases as phase (phase.key)} -
  1. - - {phase.label} - {#if phase.active} - {importElapsed}s - {/if} -
  2. - {/each} -
- {/if} - - {#if importError} -

{importError}

- {/if} -
- - {#if successMessage} -
-

{successMessage}

-
- {/if} - -
-

Importierte Inhalte werden im Freitext-Tab deines Profils gespeichert.

-

Der Crawler respektiert robots.txt und nutzt Rate-Limits.

-
-
- - diff --git a/apps/mana/apps/web/src/lib/modules/kontext/collections.ts b/apps/mana/apps/web/src/lib/modules/kontext/collections.ts deleted file mode 100644 index eaa79853b..000000000 --- a/apps/mana/apps/web/src/lib/modules/kontext/collections.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Kontext module — Dexie table accessor for the singleton document. - */ - -import { db } from '$lib/data/database'; -import type { LocalKontextDoc } from './types'; - -export const kontextDocTable = db.table('kontextDoc'); diff --git a/apps/mana/apps/web/src/lib/modules/kontext/index.ts b/apps/mana/apps/web/src/lib/modules/kontext/index.ts deleted file mode 100644 index ab7012155..000000000 --- a/apps/mana/apps/web/src/lib/modules/kontext/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Kontext module — barrel exports. - */ - -export { kontextStore } from './stores/kontext.svelte'; -export { useKontextDoc, toKontextDoc } from './queries'; -export { kontextDocTable } from './collections'; -export { KONTEXT_SINGLETON_ID } from './types'; -export type { LocalKontextDoc, KontextDoc } from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/kontext/module.config.ts b/apps/mana/apps/web/src/lib/modules/kontext/module.config.ts deleted file mode 100644 index fd0f5bfa2..000000000 --- a/apps/mana/apps/web/src/lib/modules/kontext/module.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ModuleConfig } from '$lib/data/module-registry'; - -export const kontextModuleConfig: ModuleConfig = { - appId: 'kontext', - tables: [{ name: 'kontextDoc' }], -}; diff --git a/apps/mana/apps/web/src/lib/modules/kontext/queries.ts b/apps/mana/apps/web/src/lib/modules/kontext/queries.ts deleted file mode 100644 index 0b4cbf394..000000000 --- a/apps/mana/apps/web/src/lib/modules/kontext/queries.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Kontext module — reactive query for the active-Space document. - * - * Content is encrypted at rest. Returns the row as soon as it's been - * pulled from mana-sync (every Space-creation server-bootstraps an empty - * kontextDoc — see `bootstrap-singletons.ts`); returns null only during - * the brief window before the first pull lands or for legacy Spaces - * created before the bootstrap shipped. - * - * Per-Space since Phase 2d.2: each Space has its own kontextDoc; - * Personal-Space's legacy singleton row is matched by the in-scope - * set's inclusion of the `_personal:` sentinel. - */ - -import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; -import { deriveUpdatedAt } from '$lib/data/sync'; -import { decryptRecords } from '$lib/data/crypto'; -import { scopedTable } from '$lib/data/scope/scoped-db'; -import type { KontextDoc, LocalKontextDoc } from './types'; - -export function toKontextDoc(local: LocalKontextDoc): KontextDoc { - return { - id: local.id, - content: local.content ?? '', - createdAt: local.createdAt ?? new Date().toISOString(), - updatedAt: deriveUpdatedAt(local), - }; -} - -export function useKontextDoc() { - return useLiveQueryWithDefault( - async () => { - const rows = await scopedTable('kontextDoc').toArray(); - const match = rows.find((r) => !r.deletedAt); - if (!match) return null; - const [decrypted] = await decryptRecords('kontextDoc', [match]); - return decrypted ? toKontextDoc(decrypted) : null; - }, - null as KontextDoc | null - ); -} diff --git a/apps/mana/apps/web/src/lib/modules/kontext/stores/kontext.svelte.ts b/apps/mana/apps/web/src/lib/modules/kontext/stores/kontext.svelte.ts deleted file mode 100644 index 25f3c72df..000000000 --- a/apps/mana/apps/web/src/lib/modules/kontext/stores/kontext.svelte.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Kontext Store — per-Space markdown document. - * - * Since Phase 2d.2 the module is Space-scoped: each Space has its own - * kontextDoc. Since 2026-04-26 (sync-field-meta-overhaul Punkt 2) every - * Space-creation also bootstraps an empty kontextDoc server-side via - * `bootstrapSpaceSingletons` — fresh clients pull the row from mana-sync - * instead of racing on a local insert. `getOrCreateLocalDoc()` below is - * kept as a fallback for the brief window before the first pull lands - * (and for legacy Spaces created before the bootstrap shipped). - * - * The store finds the row via `getInScopeSpaceIds()` (which matches the - * active Space plus the legacy `_personal:` sentinel so - * Personal-Space's pre-migration singleton row still renders). - * - * `content` is encrypted at rest. The Dexie creating hook stamps - * `spaceId` on new rows automatically — we just pick a fresh UUID. - */ - -import { kontextDocTable } from '../collections'; -import { encryptRecord, decryptRecords } from '$lib/data/crypto'; -import { scopedTable } from '$lib/data/scope/scoped-db'; -import { makeSystemActor, SYSTEM_BOOTSTRAP } from '@mana/shared-ai'; -import { runAsAsync } from '$lib/data/events/actor'; -import type { LocalKontextDoc } from '../types'; - -const BOOTSTRAP_ACTOR = makeSystemActor(SYSTEM_BOOTSTRAP); - -async function findForActiveSpace(): Promise { - const rows = await scopedTable('kontextDoc').toArray(); - return rows.find((r) => !r.deletedAt); -} - -/** - * Race-window fallback for the narrow window between "server bootstrap - * provisioned the row in mana_sync" and "first pull landed it in - * IndexedDB". Without this, `setContent` / `appendContent` would hit - * `update(missing-id, diff)` — a Dexie no-op that silently swallows the - * write. Insert is stamped `origin: 'system'` (via SYSTEM_BOOTSTRAP) - * so the server's bootstrap pull won't fight with it. Subsequent - * `setContent` writes stamp `origin: 'user'` as usual. - */ -async function getOrCreateLocalDoc(): Promise { - const existing = await findForActiveSpace(); - if (existing) return existing; - const newLocal: LocalKontextDoc = { - id: crypto.randomUUID(), - content: '', - }; - await encryptRecord('kontextDoc', newLocal); - await runAsAsync(BOOTSTRAP_ACTOR, async () => { - await kontextDocTable.add(newLocal); - }); - // Reload — the creating-hook stamped spaceId/authorId/actor fields. - const created = await kontextDocTable.get(newLocal.id); - if (!created) throw new Error('Failed to create kontextDoc'); - return created; -} - -export const kontextStore = { - async setContent(content: string): Promise { - const row = await getOrCreateLocalDoc(); - const diff: Partial = { - content, - }; - await encryptRecord('kontextDoc', diff); - await kontextDocTable.update(row.id, diff); - }, - - async appendContent(chunk: string): Promise { - const row = await getOrCreateLocalDoc(); - const [decrypted] = await decryptRecords('kontextDoc', [row]); - const current = decrypted?.content ?? ''; - const separator = current.trim() ? '\n\n---\n\n' : ''; - await this.setContent(`${current}${separator}${chunk}`); - }, -}; diff --git a/apps/mana/apps/web/src/lib/modules/kontext/types.ts b/apps/mana/apps/web/src/lib/modules/kontext/types.ts deleted file mode 100644 index e951da499..000000000 --- a/apps/mana/apps/web/src/lib/modules/kontext/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Kontext module types — per-Space markdown document. - * - * Since Phase 2d.2 of the space-scoped rollout, each Space can have its - * own kontextDoc (was: user-level singleton keyed by id='singleton'). - * Personal-Space's pre-migration singleton row stays usable because its - * stamped spaceId falls inside the in-scope set returned by - * getInScopeSpaceIds(); fresh rows use random UUIDs. - */ - -import type { BaseRecord } from '@mana/local-store'; - -/** - * Legacy singleton id — pre-Phase-2d.2 the whole module was one row - * keyed by this. Kept for backward-compat lookups on Personal-Space - * records that predate the refactor; new rows use crypto.randomUUID(). - */ -export const KONTEXT_SINGLETON_ID = 'singleton' as const; - -export interface LocalKontextDoc extends BaseRecord { - id: string; - content: string; -} - -export interface KontextDoc { - id: string; - content: string; - createdAt: string; - updatedAt: string; -} diff --git a/apps/mana/apps/web/src/lib/modules/notes/ListView.svelte b/apps/mana/apps/web/src/lib/modules/notes/ListView.svelte index ff5f209ee..6374a5014 100644 --- a/apps/mana/apps/web/src/lib/modules/notes/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/notes/ListView.svelte @@ -10,11 +10,13 @@ import type { ViewProps } from '$lib/app-registry'; import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui'; import { useItemContextMenu } from '$lib/data/item-context-menu.svelte'; - import { PencilSimple, Trash, PushPin } from '@mana/shared-icons'; + import { PencilSimple, Trash, PushPin, LinkSimple, Scroll } from '@mana/shared-icons'; import FloatingInputBar from '$lib/components/FloatingInputBar.svelte'; import AgentDot from '$lib/components/ai/AgentDot.svelte'; import ScopeEmptyState from '$lib/components/workbench/ScopeEmptyState.svelte'; import { hasActiveSceneScope } from '$lib/stores/scene-scope.svelte'; + import { crawlUrl, type CrawlMode } from './api'; + import { requireAuth } from '$lib/auth/require-auth.svelte'; let { navigate, goBack, params }: ViewProps = $props(); @@ -100,6 +102,60 @@ await notesStore.togglePin(id); } + // ─── URL Import ────────────────────────────────────────── + let urlImportOpen = $state(false); + let importUrl = $state(''); + let importMode = $state('single'); + let importSummarize = $state(false); + let importing = $state(false); + let importError = $state(null); + + function resetImport() { + importUrl = ''; + importMode = 'single'; + importSummarize = false; + importError = null; + } + + async function handleImport(e: Event) { + e.preventDefault(); + const trimmed = importUrl.trim(); + if (!trimmed) return; + const ok = await requireAuth({ + feature: 'notes-url-import', + reason: + 'Das Crawlen einer Web-Seite läuft serverseitig (robots.txt, Rate-Limits, optionale KI-Zusammenfassung) und erfordert ein Mana-Konto.', + }); + if (!ok) return; + importing = true; + importError = null; + try { + const result = await crawlUrl({ + url: trimmed, + mode: importMode, + summarize: importSummarize, + }); + const header = `_Quelle: ${result.sourceUrl}_\n\n`; + const note = await notesStore.createNote({ + title: result.title, + content: header + result.content, + }); + urlImportOpen = false; + resetImport(); + startEdit(note); + } catch (err) { + importError = err instanceof Error ? err.message : 'Import fehlgeschlagen'; + } finally { + importing = false; + } + } + + // ─── Space-Kontext Mutex ───────────────────────────────── + async function handleToggleSpaceContext(note: Note) { + // Mutex enforced inside the store; flagged → unset, unflagged → set. + await notesStore.markAsSpaceContext(note.isSpaceContext ? null : note.id); + } + const ctxMenu = useItemContextMenu(); let ctxMenuItems = $derived( @@ -123,6 +179,17 @@ if (target) notesStore.togglePin(target.id); }, }, + { + id: 'space-context', + label: ctxMenu.state.target.isSpaceContext + ? 'Space-Kontext lösen' + : 'Als Space-Kontext markieren', + icon: Scroll, + action: () => { + const target = ctxMenu.state.target; + if (target) handleToggleSpaceContext(target); + }, + }, { id: 'div', label: '', type: 'divider' as const }, { id: 'delete', @@ -140,6 +207,67 @@
+ +
+ {#if !urlImportOpen} + + {:else} +
+
+ + + +
+
+ + + · + +
+ {#if importError} +

{importError}

+ {/if} +
+ {/if} +
+ {#if notes.length > 5} @@ -192,6 +320,14 @@
{note.title || 'Unbenannt'} + {#if note.isSpaceContext} + + + + {/if} {#if note.isPinned}📌{/if}
{#if note.content} @@ -250,6 +386,116 @@ position: relative; } + .url-import { + display: flex; + flex-direction: column; + } + .url-import-toggle { + display: inline-flex; + align-items: center; + gap: 0.375rem; + align-self: flex-start; + padding: 0.3rem 0.5rem; + border: 1px dashed hsl(var(--color-border)); + border-radius: 0.375rem; + background: transparent; + color: hsl(var(--color-muted-foreground)); + font-size: 0.75rem; + cursor: pointer; + transition: + color 0.15s, + border-color 0.15s; + } + .url-import-toggle:hover { + color: hsl(var(--color-foreground)); + border-color: hsl(var(--color-ring)); + } + .url-import-form { + display: flex; + flex-direction: column; + gap: 0.375rem; + padding: 0.5rem; + border: 1px solid hsl(var(--color-border)); + border-radius: 0.5rem; + background: hsl(var(--color-card)); + } + .url-import-row { + display: flex; + align-items: stretch; + gap: 0.25rem; + } + .url-import-input { + flex: 1; + min-width: 0; + padding: 0.3rem 0.5rem; + border: 1px solid hsl(var(--color-border)); + border-radius: 0.375rem; + background: transparent; + color: hsl(var(--color-foreground)); + font-size: 0.8125rem; + outline: none; + } + .url-import-input:focus { + border-color: hsl(var(--color-ring)); + } + .url-import-submit { + padding: 0.3rem 0.75rem; + border: none; + border-radius: 0.375rem; + background: hsl(var(--color-primary)); + color: hsl(var(--color-primary-foreground)); + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + } + .url-import-submit:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .url-import-cancel { + padding: 0.3rem 0.5rem; + border: 1px solid hsl(var(--color-border)); + border-radius: 0.375rem; + background: transparent; + color: hsl(var(--color-muted-foreground)); + font-size: 1rem; + line-height: 1; + cursor: pointer; + } + .url-import-opts { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + color: hsl(var(--color-muted-foreground)); + } + .url-import-opts label { + display: inline-flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; + } + .url-import-opts label.disabled { + opacity: 0.5; + } + .url-import-opts .sep { + opacity: 0.4; + } + .url-import-error { + margin: 0; + font-size: 0.75rem; + color: hsl(var(--color-destructive, 0 84% 60%)); + } + + .space-context-badge { + display: inline-flex; + align-items: center; + justify-content: center; + color: hsl(var(--color-primary)); + opacity: 0.8; + } + .search-input { padding: 0.3rem 0.5rem; border-radius: 0.375rem; diff --git a/apps/mana/apps/web/src/lib/modules/kontext/api.ts b/apps/mana/apps/web/src/lib/modules/notes/api.ts similarity index 63% rename from apps/mana/apps/web/src/lib/modules/kontext/api.ts rename to apps/mana/apps/web/src/lib/modules/notes/api.ts index 6af5b8dba..5956d7ae6 100644 --- a/apps/mana/apps/web/src/lib/modules/kontext/api.ts +++ b/apps/mana/apps/web/src/lib/modules/notes/api.ts @@ -1,8 +1,10 @@ /** - * Kontext API client — talks to apps/api `POST /api/v1/context/import-url`. + * Notes API client — talks to apps/api `POST /api/v1/notes/import-url`. * - * The server route lives under /context for historical reasons (shared - * crawler + LLM wrapper). Only the kontext singleton consumes it. + * Crawls a URL via mana-crawler, optionally summarises the result with + * mana-llm, and returns the markdown payload. The caller decides what + * to do with the result — typically `notesStore.createNote({ title, + * content })` to materialise it as a Note. */ import { authStore } from '$lib/stores/auth.svelte'; @@ -25,10 +27,10 @@ export interface ImportResponse { pageCount: number; } -export async function crawlUrlViaApi(input: ImportInput): Promise { +export async function crawlUrl(input: ImportInput): Promise { const token = await authStore.getValidToken(); if (!token) throw new Error('not authenticated'); - const res = await fetch(`${getManaApiUrl()}/api/v1/context/import-url`, { + const res = await fetch(`${getManaApiUrl()}/api/v1/notes/import-url`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/apps/mana/apps/web/src/lib/modules/notes/queries.ts b/apps/mana/apps/web/src/lib/modules/notes/queries.ts index 51e67135a..f6201c3d5 100644 --- a/apps/mana/apps/web/src/lib/modules/notes/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/notes/queries.ts @@ -35,6 +35,7 @@ export function toNote(local: LocalNote): Note { transcriptModel: local.transcriptModel ?? null, isPinned: local.isPinned, isArchived: local.isArchived, + isSpaceContext: local.isSpaceContext ?? false, createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local), }; @@ -63,6 +64,25 @@ export function useAllNotes() { }, [] as Note[]); } +/** + * The single note marked `isSpaceContext: true` in the active Space, or + * null if no note holds that flag yet. Used by the AI Mission Runner to + * auto-inject "what's this Space about" into every planner call. The + * store guarantees mutex (max 1 per Space), so `find` is enough. + */ +export function useSpaceContextNote() { + return useScopedLiveQuery( + async () => { + const raw = await scopedForModule('notes', 'notes').toArray(); + const match = raw.find((n) => !n.deletedAt && n.isSpaceContext === true); + if (!match) return null; + const [decrypted] = await decryptRecords('notes', [match]); + return decrypted ? toNote(decrypted) : null; + }, + null as Note | null + ); +} + /** Single note by id, decrypted. Used by detail views. */ export function useNote(id: string) { return useScopedLiveQuery( diff --git a/apps/mana/apps/web/src/lib/modules/notes/stores/notes.svelte.ts b/apps/mana/apps/web/src/lib/modules/notes/stores/notes.svelte.ts index d3da975d4..2fc094ece 100644 --- a/apps/mana/apps/web/src/lib/modules/notes/stores/notes.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/notes/stores/notes.svelte.ts @@ -121,4 +121,41 @@ export const notesStore = { isArchived: true, }); }, + + /** + * Mark `id` as the active Space's standing-context note for AI-Runner + * auto-injection. Mutex: clears `isSpaceContext` on every other note + * in the same Space first so the index can assume at most one `true` + * row per `spaceId`. Pass `null` to unmark without setting a new one. + * + * The mutex sweep deliberately writes to *every* currently-flagged + * row even though there should only ever be one — that way a sync + * race that briefly let two rows hold the flag self-heals on the + * next mark. + */ + async markAsSpaceContext(id: string | null): Promise { + const target = id ? await noteTable.get(id) : null; + const targetSpaceId = (target as { spaceId?: string } | undefined)?.spaceId; + + // Clear the flag on every currently-flagged note. If we have a target + // Space, restrict to that Space; if not (id === null), the caller + // asked for an unset of the active Space's flagged note(s) — same query + // scoped via Dexie filter. spaceId is auto-stamped by the Dexie + // creating-hook so it's always present at runtime even though the + // LocalNote interface doesn't declare it. + const flagged = await noteTable + .filter((n) => { + const noteSpaceId = (n as { spaceId?: string }).spaceId; + return n.isSpaceContext === true && (!targetSpaceId || noteSpaceId === targetSpaceId); + }) + .toArray(); + for (const n of flagged) { + if (n.id === id) continue; + await noteTable.update(n.id, { isSpaceContext: false }); + } + + if (id) { + await noteTable.update(id, { isSpaceContext: true }); + } + }, }; diff --git a/apps/mana/apps/web/src/lib/modules/notes/types.ts b/apps/mana/apps/web/src/lib/modules/notes/types.ts index fc0dff72d..050b5c456 100644 --- a/apps/mana/apps/web/src/lib/modules/notes/types.ts +++ b/apps/mana/apps/web/src/lib/modules/notes/types.ts @@ -16,6 +16,15 @@ export interface LocalNote extends BaseRecord { transcriptModel?: string | null; isPinned: boolean; isArchived: boolean; + /** + * Marks this note as the active Space's standing-context for AI Mission + * Runner auto-injection. Mutually exclusive within a Space — the store's + * markAsSpaceContext() unsets the flag on every other note in the same + * Space before setting it here, so the index can assume at most one + * `true` row per `spaceId`. Optional on the type because legacy rows + * predate the field; absent === false. + */ + isSpaceContext?: boolean; } // ─── Domain Types ───────────────────────────────────────── @@ -28,6 +37,7 @@ export interface Note { transcriptModel: string | null; isPinned: boolean; isArchived: boolean; + isSpaceContext: boolean; createdAt: string; updatedAt: string; } diff --git a/apps/mana/apps/web/src/lib/modules/profile/ContextFreeform.svelte b/apps/mana/apps/web/src/lib/modules/profile/ContextFreeform.svelte index b5d5c77aa..7e538fc3d 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/ContextFreeform.svelte +++ b/apps/mana/apps/web/src/lib/modules/profile/ContextFreeform.svelte @@ -7,7 +7,7 @@ import { useUserContext } from './queries'; import { userContextStore } from './stores/user-context.svelte'; import { PencilSimple, Eye, LinkSimple, X } from '@mana/shared-icons'; - import { crawlUrlViaApi, type CrawlMode } from '$lib/modules/kontext/api'; + import { crawlUrl, type CrawlMode } from '$lib/modules/notes/api'; import { requireAuth } from '$lib/auth/require-auth.svelte'; import { _ } from 'svelte-i18n'; @@ -116,7 +116,7 @@ ); } try { - const result = await crawlUrlViaApi({ + const result = await crawlUrl({ url: trimmed, mode: importMode, summarize: importSummarize, diff --git a/apps/mana/apps/web/src/lib/modules/writing/components/ReferencePicker.svelte b/apps/mana/apps/web/src/lib/modules/writing/components/ReferencePicker.svelte index 564509cfa..90eb47838 100644 --- a/apps/mana/apps/web/src/lib/modules/writing/components/ReferencePicker.svelte +++ b/apps/mana/apps/web/src/lib/modules/writing/components/ReferencePicker.svelte @@ -8,7 +8,7 @@ - note → searchable list of notes - library → searchable list of library entries - url → freeform URL input + optional context note - - kontext → space's kontext-doc singleton (one-click add) + - kontext → space's standing-context Note (one-click add; the Note flagged isSpaceContext) - goal → searchable list of goals - me-image → searchable list of profile reference images @@ -18,9 +18,8 @@ - - - Web-Context - Mana - - - - - diff --git a/packages/credits/src/operations.ts b/packages/credits/src/operations.ts index 204b6c06f..92dff256b 100644 --- a/packages/credits/src/operations.ts +++ b/packages/credits/src/operations.ts @@ -50,8 +50,8 @@ export enum CreditOperationType { // Traces - City guide generation AI_GUIDE_GENERATION = 'ai_guide_generation', - // Context - AI text generation - AI_CONTEXT_GENERATION = 'ai_context_generation', + // Notes - URL crawl + optional summary into a Note + NOTES_IMPORT_URL = 'notes_import_url', // General AI features AI_SMART_SCHEDULING = 'ai_smart_scheduling', @@ -104,7 +104,7 @@ export const CREDIT_COSTS: Record = { [CreditOperationType.AI_PLANT_ANALYSIS]: 2, [CreditOperationType.AI_GUIDE_GENERATION]: 5, - [CreditOperationType.AI_CONTEXT_GENERATION]: 2, + [CreditOperationType.NOTES_IMPORT_URL]: 1, [CreditOperationType.AI_SMART_SCHEDULING]: 2, [CreditOperationType.AI_SUGGESTIONS]: 2, @@ -259,12 +259,12 @@ export const OPERATION_METADATA: Record app: 'traces', }, - // Context - [CreditOperationType.AI_CONTEXT_GENERATION]: { - name: 'AI Text Generation', - description: 'Generate or transform text with AI', + // Notes + [CreditOperationType.NOTES_IMPORT_URL]: { + name: 'URL Import (Note)', + description: 'Crawl a URL and create a Markdown Note (optional AI summary)', category: CreditCategory.AI, - app: 'context', + app: 'notes', }, // General AI diff --git a/packages/shared-ai/src/actor.ts b/packages/shared-ai/src/actor.ts index 2fb90e05d..95fe6bc1c 100644 --- a/packages/shared-ai/src/actor.ts +++ b/packages/shared-ai/src/actor.ts @@ -35,9 +35,9 @@ export const SYSTEM_STREAM = 'system:stream'; export const SYSTEM_MISSION_RUNNER = 'system:mission-runner'; /** * Client-side singleton bootstrap. Stamped on the rare race-window - * `getOrCreateLocalDoc()` insert in `userContextStore` / `kontextStore` - * — a structural twin of mana-auth's server-side bootstrap (which uses - * the `'system:bootstrap'` principalId on the wire). Maps to + * `getOrCreateLocalDoc()` insert in `userContextStore` — a structural + * twin of mana-auth's server-side bootstrap (which uses the + * `'system:bootstrap'` principalId on the wire). Maps to * `origin='system'` via `originFromActor`, so the conflict-gate exempts * it from the user-write codepath. */ diff --git a/packages/shared-utils/src/analytics.ts b/packages/shared-utils/src/analytics.ts index 925dd009b..c5b1ab3e4 100644 --- a/packages/shared-utils/src/analytics.ts +++ b/packages/shared-utils/src/analytics.ts @@ -153,7 +153,6 @@ const track = { contacts: createModuleTracker('contacts'), cards: createModuleTracker('cards'), mana: createModuleTracker('mana'), - context: createModuleTracker('context'), skilltree: createModuleTracker('skilltree'), food: createModuleTracker('food'), plants: createModuleTracker('plants'), @@ -352,18 +351,6 @@ export const ManaEvents = { track.mana('feature_blocked_by_auth', params), }; -/** - * Context App Events - */ -export const ContextEvents = { - documentCreated: (type: string) => track.context('document_created', { type }), - documentDeleted: () => track.context('document_deleted'), - documentPinned: (pinned: boolean) => track.context('document_pinned', { pinned }), - spaceCreated: () => track.context('space_created'), - spaceDeleted: () => track.context('space_deleted'), - aiGenerated: () => track.context('ai_generated'), -}; - /** * SkillTree App Events */ diff --git a/services/mana-auth/src/auth/better-auth.config.ts b/services/mana-auth/src/auth/better-auth.config.ts index c551ad160..6e4dcc0ad 100644 --- a/services/mana-auth/src/auth/better-auth.config.ts +++ b/services/mana-auth/src/auth/better-auth.config.ts @@ -47,7 +47,6 @@ import { assertSpaceIsDeletable, createPersonalSpaceFor, } from '../spaces'; -import { bootstrapSpaceSingletons } from '../services/bootstrap-singletons'; // Re-export so existing imports (`import { TRUSTED_ORIGINS } from './better-auth.config'`) // keep working. New code should import from './sso-origins' directly. @@ -98,10 +97,10 @@ export interface BetterAuthWebAuthnOptions { * Create Better Auth instance * * @param databaseUrl - PostgreSQL connection URL for the auth DB - * @param syncDatabaseUrl - PostgreSQL connection URL for `mana_sync`. The - * personal-space + organization hooks bootstrap per-Space singletons - * into `sync_changes` so fresh clients pull the row instead of racing - * on a local insert. See `bootstrapSpaceSingletons`. + * @param syncDatabaseUrl - PostgreSQL connection URL for `mana_sync`. Held + * for use by the per-user `userContext` bootstrap; currently no + * per-Space singletons are written here (the kontextDoc that used to + * live here was retired in the Option-B cleanup). * @param webauthn - WebAuthn settings for the passkey plugin * @returns Better Auth instance */ @@ -272,24 +271,6 @@ export function createBetterAuth( name: user.name, accessTier: (user as { accessTier?: string | null }).accessTier, }); - // Bootstrap the personal Space's kontextDoc only on a - // real first-time creation. The `created: false` path - // means a previous signup retry already provisioned it - // and the doc has been bootstrapped before. Failures - // are logged but do not abort signup — the webapp's - // `ensureDoc()` fallback still creates the row on the - // first write attempt. - if (result.created) { - bootstrapSpaceSingletons(result.organizationId, user.id, getSyncSql()).catch( - (err: unknown) => { - logger.error('[auth] bootstrapSpaceSingletons (personal) failed', { - userId: user.id, - organizationId: result.organizationId, - err: err instanceof Error ? err.message : String(err), - }); - } - ); - } }, }, }, @@ -378,32 +359,16 @@ export function createBetterAuth( /** * Spaces — enforce that every organization carries a valid * `metadata.type` (the Space type), and block deletion of the - * user's personal space. After-create bootstraps per-Space - * singletons (currently `kontextDoc`) into mana_sync so fresh - * clients pull the row instead of racing on a local insert. - * Personal-space gets the same bootstrap, but from - * `databaseHooks.user.create.after` because Better Auth's - * `afterCreateOrganization` does not fire on the implicit - * personal-space creation that runs inside the user-create - * hook (createPersonalSpaceFor writes to `organizations` - * directly via Drizzle). See docs/plans/spaces-foundation.md - * and ../spaces/metadata.ts. + * user's personal space. The per-Space `kontextDoc` singleton + * that used to be bootstrapped here was retired in favour of + * the user-driven `notes.isSpaceContext` flag (Option B + * cleanup), so the after-create hook is currently empty — + * kept as a hook anchor for future per-Space bootstrap needs. */ organizationHooks: { beforeCreateOrganization: async ({ organization }) => { assertValidSpaceMetadataForCreate(organization.metadata); }, - afterCreateOrganization: async ({ organization, user }) => { - bootstrapSpaceSingletons(organization.id, user.id, getSyncSql()).catch( - (err: unknown) => { - logger.error('[auth] bootstrapSpaceSingletons (org-hook) failed', { - userId: user.id, - organizationId: organization.id, - err: err instanceof Error ? err.message : String(err), - }); - } - ); - }, beforeDeleteOrganization: async ({ organization }) => { assertSpaceIsDeletable(organization.metadata); }, diff --git a/services/mana-auth/src/index.ts b/services/mana-auth/src/index.ts index 756f8ad30..2c525f67d 100644 --- a/services/mana-auth/src/index.ts +++ b/services/mana-auth/src/index.ts @@ -149,10 +149,10 @@ app.route('/api/v1/me/ai-mission-grant', createAiMissionGrantRoutes(missionGrant app.route('/api/v1/me/onboarding', createOnboardingRoutes(db)); // ─── Singleton Bootstrap ──────────────────────────────────── -// Idempotent reconciliation endpoint for per-user + per-Space sync -// singletons (userContext, kontextDoc). Webapp boot calls this once; -// signup-time hooks remain the happy path. See -// docs/plans/sync-field-meta-overhaul.md and routes/me-bootstrap.ts. +// Idempotent reconciliation endpoint for the per-user `userContext` +// singleton. Webapp boot calls this once; the signup-time hook remains +// the happy path. See docs/plans/sync-field-meta-overhaul.md and +// routes/me-bootstrap.ts. app.route('/api/v1/me/bootstrap-singletons', createMeBootstrapRoutes(db, config.syncDatabaseUrl)); // ─── Settings ────────────────────────────────────────────── diff --git a/services/mana-auth/src/routes/me-bootstrap.ts b/services/mana-auth/src/routes/me-bootstrap.ts index 4acad015c..089183090 100644 --- a/services/mana-auth/src/routes/me-bootstrap.ts +++ b/services/mana-auth/src/routes/me-bootstrap.ts @@ -2,34 +2,34 @@ * Singleton bootstrap endpoint. * * `POST /api/v1/me/bootstrap-singletons` — idempotently provisions the - * per-user `userContext` singleton and the per-Space `kontextDoc` for - * every Space the caller is a member of. Called once per webapp boot - * as a reconciliation belt-and-suspenders for the signup-time hooks - * (databaseHooks.user.create.after + organizationHooks.afterCreateOrganization). + * per-user `userContext` singleton. Called once per webapp boot as a + * reconciliation belt-and-suspenders for the signup-time hook + * (databaseHooks.user.create.after). * - * Why both: the signup hooks are zero-latency happy-path bootstraps but + * Why both: the signup hook is a zero-latency happy-path bootstrap but * fire-and-forget — a transient mana_sync outage during signup leaves * the user with no singleton and no signal that anything is wrong. The * boot-time endpoint converges to the right state on every load. - * Idempotency in the bootstrap functions makes double-invocation + * Idempotency in the bootstrap function makes double-invocation * harmless. * * The endpoint is deliberately simple: no body, no parameters. The - * caller's identity (and thus the userId + space-membership list) - * comes from the JWT. + * caller's identity (and thus the userId) comes from the JWT. + * + * Per-Space singletons used to be bootstrapped here too (kontextDoc), + * but the kontextDoc table was retired in favour of the user-driven + * `notes.isSpaceContext` flag — there is nothing to bootstrap per + * Space anymore. The response shape keeps the `spaces` map for + * backwards compatibility with older webapp builds; it is always + * empty now. */ import { Hono } from 'hono'; -import { eq } from 'drizzle-orm'; import postgres from 'postgres'; import { logger } from '@mana/shared-hono'; import type { AuthUser } from '../middleware/jwt-auth'; import type { Database } from '../db/connection'; -import { members } from '../db/schema/organizations'; -import { - bootstrapUserSingletons, - bootstrapSpaceSingletons, -} from '../services/bootstrap-singletons'; +import { bootstrapUserSingletons } from '../services/bootstrap-singletons'; export interface BootstrapResponse { ok: true; @@ -40,7 +40,7 @@ export interface BootstrapResponse { } export function createMeBootstrapRoutes( - db: Database, + _db: Database, syncDatabaseUrl: string ): Hono<{ Variables: { user: AuthUser } }> { // Lazy module-scoped postgres pool. Mirrors routes/auth.ts and @@ -71,38 +71,6 @@ export function createMeBootstrapRoutes( return c.json({ ok: false, error: 'userContext bootstrap failed' }, 500); } - // Bootstrap every Space the user is a member of. The owner of a - // Space is the canonical writer for its singletons, but RLS - // only gates by user_id (writer); the membership-aware pull - // delivers the row to every member regardless of which member - // inserted it. If the owner's bootstrap failed at signup time - // and a non-owner member calls this endpoint first, the - // member's bootstrap stands in. - const memberRows = await db - .select({ organizationId: members.organizationId }) - .from(members) - .where(eq(members.userId, user.userId)); - - for (const row of memberRows) { - const spaceId = row.organizationId; - try { - result.bootstrapped.spaces[spaceId] = await bootstrapSpaceSingletons( - spaceId, - user.userId, - syncSql - ); - } catch (err) { - logger.error('[me/bootstrap-singletons] space failed', { - userId: user.userId, - spaceId, - err: err instanceof Error ? err.message : String(err), - }); - // Don't abort — surface the per-space outcome and - // continue. The caller can retry on next boot. - result.bootstrapped.spaces[spaceId] = false; - } - } - return c.json(result); }); } diff --git a/services/mana-auth/src/services/bootstrap-singletons.ts b/services/mana-auth/src/services/bootstrap-singletons.ts index d53f36bfd..42ac994e5 100644 --- a/services/mana-auth/src/services/bootstrap-singletons.ts +++ b/services/mana-auth/src/services/bootstrap-singletons.ts @@ -1,28 +1,27 @@ /** * Server-side singleton bootstrap. * - * On first user-creation and Space-creation, write the singleton records - * that the webapp would otherwise create on demand via `ensureDoc()` / - * `getOrCreateLocalDoc()`. This makes the bootstrap deterministic — every - * fresh client pulls the singleton from mana-sync instead of racing on a - * local insert that the next pull would clobber. + * On first user-creation, write the singleton records that the webapp + * would otherwise create on demand via `ensureDoc()` / + * `getOrCreateLocalDoc()`. This makes the bootstrap deterministic — + * every fresh client pulls the singleton from mana-sync instead of + * racing on a local insert that the next pull would clobber. * * Currently bootstrapped: * - `userContext` — per-user. The structured profile + freeform markdown * blob keyed by `id='singleton'`. Default shape mirrors the webapp's * `emptyUserContext()` factory in `profile/types.ts`. - * - `kontextDoc` — per-Space. The freeform markdown context document - * (encrypted at rest in normal client writes; bootstrap writes the - * empty default in plaintext, the client's `decryptRecord` skips - * non-envelope strings so this is safe). * - * Idempotency: each function performs an existence-check on + * (The per-Space `kontextDoc` singleton was retired — the + * notes.isSpaceContext flag now carries the same role, and a flagged + * Note is created on demand by the user, not bootstrapped empty.) + * + * Idempotency: the function performs an existence-check on * `sync_changes` before inserting — if a row matching the singleton's * scope already exists, the call is a no-op. This makes the bootstrap * safe to run from multiple sources without producing duplicate rows: - * - signup-time hooks (databaseHooks.user.create.after, - * organizationHooks.afterCreateOrganization) — fire on the happy - * path + * - signup-time hook (databaseHooks.user.create.after) — fires on the + * happy path * - boot-time endpoint (POST /api/v1/me/bootstrap-singletons) — fires * on every webapp boot as a reconciliation belt-and-suspenders * @@ -88,19 +87,6 @@ function emptyUserContextData(userId: string): Record { }; } -/** - * Default content for a new Space's `kontextDoc`. Just an id + empty - * content — the user fills in the markdown later. Encryption is skipped - * (empty string reveals nothing); the client's `decryptRecord` is - * tolerant of plaintext values for encrypted-registry fields. - */ -function emptyKontextDocData(): Record { - return { - id: crypto.randomUUID(), - content: '', - }; -} - /** * Insert the per-user singletons into mana_sync.sync_changes. Idempotent * — skips the insert if a row for `(userContext, 'singleton', userId)` @@ -149,62 +135,3 @@ export async function bootstrapUserSingletons( `; return true; } - -/** - * Insert the per-Space singletons into mana_sync.sync_changes. Idempotent - * — skips the insert if any `kontextDoc` row already exists for the - * given `spaceId` (regardless of writer). Called from: - * - `databaseHooks.user.create.after` once `createPersonalSpaceFor` - * returns `created: true` (personal-space happy path) - * - `organizationHooks.afterCreateOrganization` (brand / club / - * family / team / practice happy path) - * - `POST /api/v1/me/bootstrap-singletons` for every space the - * caller is a member of (boot-time reconciliation) - * - * `ownerUserId` is the writer (RLS guard); `spaceId` is the data scope. - * For non-personal Spaces the inviting user remains the writer — joining - * members will receive the row via the membership-aware pull. If the - * inviter's bootstrap somehow failed and a member triggers it later via - * the endpoint, the member becomes the writer; the row is still - * delivered to all members via the membership-aware pull. - * - * Returns true if an insert was actually written, false if the - * idempotency check skipped it. - */ -export async function bootstrapSpaceSingletons( - spaceId: string, - ownerUserId: string, - syncSql: ReturnType -): Promise { - if (!spaceId) throw new Error('bootstrapSpaceSingletons: empty spaceId'); - if (!ownerUserId) throw new Error('bootstrapSpaceSingletons: empty ownerUserId'); - - const existing = await syncSql>` - SELECT 1 AS exists - FROM sync_changes - WHERE table_name = 'kontextDoc' - AND space_id = ${spaceId} - LIMIT 1 - `; - if (existing.length > 0) return false; - - const now = new Date().toISOString(); - const data = emptyKontextDocData(); - const fieldMeta = buildFieldMeta(data, now); - - await syncSql` - INSERT INTO sync_changes ( - app_id, table_name, record_id, user_id, space_id, op, data, - field_meta, client_id, schema_version, actor, origin - ) - VALUES ( - 'kontext', 'kontextDoc', ${data.id as string}, ${ownerUserId}, ${spaceId}, 'insert', - ${syncSql.json(data as never)}, - ${syncSql.json(fieldMeta as never)}, - ${BOOTSTRAP_CLIENT_ID}, 1, - ${syncSql.json(BOOTSTRAP_ACTOR as never)}, - ${BOOTSTRAP_ORIGIN} - ) - `; - return true; -}