feat(notes): isSpaceContext flag replaces kontext module (Option B)

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-29 00:06:34 +02:00
parent 054b9e5beb
commit 8fbdc6db77
37 changed files with 496 additions and 983 deletions

View file

@ -28,7 +28,7 @@ import { calendarRoutes } from './modules/calendar/routes';
import { contactsRoutes } from './modules/contacts/routes'; import { contactsRoutes } from './modules/contacts/routes';
import { musicRoutes } from './modules/music/routes'; import { musicRoutes } from './modules/music/routes';
import { chatRoutes } from './modules/chat/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 { pictureRoutes } from './modules/picture/routes';
import { profileRoutes } from './modules/profile/routes'; import { profileRoutes } from './modules/profile/routes';
import { wardrobeRoutes } from './modules/wardrobe/routes'; import { wardrobeRoutes } from './modules/wardrobe/routes';
@ -94,10 +94,10 @@ app.use('/api/*', authMiddleware());
// their own records. // their own records.
const RESOURCE_MODULES = [ const RESOURCE_MODULES = [
'chat', 'chat',
'context',
'food', 'food',
'guides', 'guides',
'news-research', 'news-research',
'notes',
'picture', 'picture',
'plants', 'plants',
'research', 'research',
@ -121,7 +121,7 @@ app.route('/api/v1/calendar', calendarRoutes);
app.route('/api/v1/contacts', contactsRoutes); app.route('/api/v1/contacts', contactsRoutes);
app.route('/api/v1/music', musicRoutes); app.route('/api/v1/music', musicRoutes);
app.route('/api/v1/chat', chatRoutes); 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/picture', pictureRoutes);
app.route('/api/v1/profile', profileRoutes); app.route('/api/v1/profile', profileRoutes);
app.route('/api/v1/wardrobe', wardrobeRoutes); app.route('/api/v1/wardrobe', wardrobeRoutes);

View file

@ -1,8 +1,11 @@
/** /**
* Context module AI text generation + token estimation * Notes module server-side helpers.
* Ported from apps/context/apps/server
* *
* 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'; import { Hono } from 'hono';
@ -16,8 +19,6 @@ const DEFAULT_SUMMARY_MODEL = MANA_LLM.FAST_TEXT;
const routes = new Hono<{ Variables: AuthVariables }>(); const routes = new Hono<{ Variables: AuthVariables }>();
// ─── URL Import (crawler → optional LLM summary → document) ──
const DEEP_MAX_PAGES = 20; const DEEP_MAX_PAGES = 20;
const CRAWL_POLL_INTERVAL_MS = 1500; const CRAWL_POLL_INTERVAL_MS = 1500;
const CRAWL_TIMEOUT_MS = 90_000; 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 * Local LLMs love to wrap Markdown in ```markdown fences or prepend
* a "Hier ist die Zusammenfassung:" preamble. Strip those so the * 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 { function sanitizeSummary(raw: string): string {
let s = raw.trim(); 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); const fenceMatch = s.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n?```\s*$/i);
if (fenceMatch) s = fenceMatch[1].trim(); if (fenceMatch) s = fenceMatch[1].trim();
// Drop a single-line preamble that ends with a colon (LLM chatter).
const lines = s.split('\n'); const lines = s.split('\n');
if (lines.length > 2 && /^[^#\n].{0,80}:\s*$/.test(lines[0].trim())) { if (lines.length > 2 && /^[^#\n].{0,80}:\s*$/.test(lines[0].trim())) {
s = lines.slice(1).join('\n').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+/, '## '); s = s.replace(/^#\s+/, '## ');
return s; return s;
} }
@ -73,7 +70,7 @@ routes.post('/import-url', async (c) => {
} }
const creditCost = summarize ? 5 : 1; 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) { if (!validation.hasCredits) {
return c.json( return c.json(
{ {
@ -147,7 +144,7 @@ routes.post('/import-url', async (c) => {
{ {
role: 'system', role: 'system',
content: 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: ' + 'Antworte ausschließlich in sauberem Markdown. Gliedere in H2-Abschnitte: ' +
'"## Überblick", "## Kernaussagen", "## Details". Nutze die Sprache der Quelle. ' + '"## Überblick", "## Kernaussagen", "## Details". Nutze die Sprache der Quelle. ' +
'Schreibe die Antwort direkt, ohne Einleitung ("Hier ist…"), ohne Schlussformel, ' + 'Schreibe die Antwort direkt, ohne Einleitung ("Hier ist…"), ohne Schlussformel, ' +
@ -175,7 +172,7 @@ routes.post('/import-url', async (c) => {
await consumeCredits( await consumeCredits(
userId, userId,
'AI_CONTEXT_IMPORT_URL', 'NOTES_IMPORT_URL',
creditCost, creditCost,
`URL import (${mode}${summarize ? ' + summary' : ''})` `URL import (${mode}${summarize ? ' + summary' : ''})`
); );
@ -194,74 +191,4 @@ routes.post('/import-url', async (c) => {
} }
}); });
// ─── AI Generation (server-only: mana-llm) ────────────────── export { routes as notesRoutes };
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 };

View file

@ -68,7 +68,6 @@ import {
Question, Question,
ChatCircleDots, ChatCircleDots,
SquaresFour, SquaresFour,
Scroll,
Spiral, Spiral,
Crown, Crown,
ShootingStar, ShootingStar,
@ -576,16 +575,6 @@ registerApp({
paramKey: 'conversationId', paramKey: 'conversationId',
}); });
registerApp({
id: 'kontext',
name: 'Web-Context',
color: '#A78B6F',
icon: Scroll,
views: {
list: { load: () => import('$lib/modules/kontext/KontextView.svelte') },
},
});
registerApp({ registerApp({
id: 'times', id: 'times',
name: 'Times', name: 'Times',

View file

@ -175,18 +175,6 @@ export const MODULE_HELP: Record<string, ModuleHelp> = {
'Nutze Vorlagen für wiederkehrende Aufgaben', '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: { context: {
description: description:
'Strukturiertes Profil — Interessen, Tagesablauf, Ziele, Ernährung. Hilft der AI dich besser zu verstehen.', 'Strukturiertes Profil — Interessen, Tagesablauf, Ziele, Ernährung. Hilft der AI dich besser zu verstehen.',

View file

@ -36,13 +36,7 @@
* sync with `services/mana-ai/src/db/resolvers/index.ts`. A mission * sync with `services/mana-ai/src/db/resolvers/index.ts`. A mission
* referencing any of these tables triggers the dialog. * referencing any of these tables triggers the dialog.
*/ */
const ENCRYPTED_SERVER_TABLES = new Set([ const ENCRYPTED_SERVER_TABLES = new Set(['notes', 'tasks', 'events', 'journalEntries']);
'notes',
'tasks',
'events',
'journalEntries',
'kontextDoc',
]);
interface Props { interface Props {
/** Mission to issue the grant for. Required — the dialog reads /** Mission to issue the grant for. Required — the dialog reads

View file

@ -2,14 +2,19 @@
* Default input resolvers. * Default input resolvers.
* *
* Registered from `setup.ts` so the production MissionRunner can load * Registered from `setup.ts` so the production MissionRunner can load
* notes / kontext / goals without every module having to know about the * notes / goals / profile / todo / calendar without every module having
* AI subsystem. Modules that need special projection logic register their * to know about the AI subsystem. Modules that need special projection
* own resolver on init and override these defaults. * 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 { db } from '../../database';
import { decryptRecords } from '../../crypto'; import { decryptRecords } from '../../crypto';
import { scopedTable } from '../../scope/scoped-db';
import { registerInputResolver } from './input-resolvers'; import { registerInputResolver } from './input-resolvers';
import { registerInputIndexer } from './input-index'; import { registerInputIndexer } from './input-index';
import type { InputResolver } from './input-resolvers'; import type { InputResolver } from './input-resolvers';
@ -20,6 +25,7 @@ interface NoteLike {
title?: string; title?: string;
content?: string; content?: string;
deletedAt?: string; deletedAt?: string;
isSpaceContext?: boolean;
} }
const notesResolver: InputResolver = async (ref) => { 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<KontextDocLike>('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) ────────── // ── User Context (structured profile + freeform) ──────────
interface UserContextLike { interface UserContextLike {
@ -155,35 +143,25 @@ const notesIndexer: InputIndexer = async () => {
const all = await db.table<NoteLike>('notes').toArray(); const all = await db.table<NoteLike>('notes').toArray();
const visible = all.filter((n) => !n.deletedAt); const visible = all.filter((n) => !n.deletedAt);
const decrypted = await decryptRecords('notes', visible); const decrypted = await decryptRecords('notes', visible);
return decrypted const candidates = decrypted.map<InputCandidate>((n) => ({
.map<InputCandidate>((n) => ({ module: 'notes',
module: 'notes', table: 'notes',
table: 'notes', id: n.id,
id: n.id, label: (n.isSpaceContext ? '★ ' : '') + ((n.title && n.title.trim()) || '(ohne Titel)'),
label: (n.title && n.title.trim()) || '(ohne Titel)', hint: n.isSpaceContext
hint: n.content ? `${n.content.slice(0, 60).replace(/\s+/g, ' ')}` : undefined, ? 'Space-Kontext (auto-injected)'
})) : n.content
.slice(0, 200); // cap — Mission picker isn't meant to list thousands ? `${n.content.slice(0, 60).replace(/\s+/g, ' ')}`
}; : undefined,
}));
const kontextIndexer: InputIndexer = async () => { // Sort: space-context-flagged notes first, then alphabetical.
// Per-Space since Phase 2d.2: the kontextDoc for the active Space is candidates.sort((a, b) => {
// the only candidate we surface to the picker. Personal-Space's legacy const aFirst = a.label.startsWith('★ ');
// singleton row is matched via the `_personal:<userId>` sentinel in const bFirst = b.label.startsWith('★ ');
// scopedTable's getInScopeSpaceIds(); Shared/Brand/Family Spaces that if (aFirst !== bFirst) return aFirst ? -1 : 1;
// haven't yet authored a kontextDoc simply return an empty list. return a.label.localeCompare(b.label);
const rows = await scopedTable<KontextDocLike, string>('kontextDoc').toArray(); });
const match = rows[0]; return candidates.slice(0, 200); // cap — Mission picker isn't meant to list thousands
if (!match) return [];
return [
{
module: 'kontext',
table: 'kontextDoc',
id: match.id,
label: 'Kontext-Dokument',
hint: 'Dein zentrales Markdown-Dokument für diesen Space',
},
];
}; };
const goalsIndexer: InputIndexer = async () => { const goalsIndexer: InputIndexer = async () => {
@ -303,13 +281,11 @@ let registered = false;
export function registerDefaultInputResolvers(): void { export function registerDefaultInputResolvers(): void {
if (registered) return; if (registered) return;
registerInputResolver('notes', notesResolver); registerInputResolver('notes', notesResolver);
registerInputResolver('kontext', kontextResolver);
registerInputResolver('profile', userContextResolver); registerInputResolver('profile', userContextResolver);
registerInputResolver('goals', goalsResolver); registerInputResolver('goals', goalsResolver);
registerInputResolver('todo', tasksResolver); registerInputResolver('todo', tasksResolver);
registerInputResolver('calendar', calendarResolver); registerInputResolver('calendar', calendarResolver);
registerInputIndexer('notes', notesIndexer); registerInputIndexer('notes', notesIndexer);
registerInputIndexer('kontext', kontextIndexer);
registerInputIndexer('profile', userContextIndexer); registerInputIndexer('profile', userContextIndexer);
registerInputIndexer('goals', goalsIndexer); registerInputIndexer('goals', goalsIndexer);
registerInputIndexer('todo', tasksIndexer); registerInputIndexer('todo', tasksIndexer);

View file

@ -3,8 +3,8 @@
* *
* Calls `POST /api/v1/me/bootstrap-singletons` on every authenticated * Calls `POST /api/v1/me/bootstrap-singletons` on every authenticated
* boot. The server-side endpoint provisions any missing per-user * boot. The server-side endpoint provisions any missing per-user
* (`userContext`) and per-Space (`kontextDoc`) singletons in * `userContext` singleton in `mana_sync.sync_changes`. Idempotent a
* `mana_sync.sync_changes`. Idempotent a second call is a no-op. * second call is a no-op.
* *
* Why call it on boot when the signup-time hooks already do this work: * 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 * the hooks are fire-and-forget and a transient mana_sync outage during
@ -14,9 +14,9 @@
* the first write. * the first write.
* *
* Best-effort: failures are swallowed and logged. The webapp's * Best-effort: failures are swallowed and logged. The webapp's
* fallback paths (`getOrCreateLocalDoc()` in `userContextStore` / * fallback path (`getOrCreateLocalDoc()` in `userContextStore`) still
* `kontextStore`) still cover the rare race where a write happens * covers the rare race where a write happens before the bootstrap row
* before the bootstrap row arrives. * arrives.
*/ */
import { browser } from '$app/environment'; import { browser } from '$app/environment';

View file

@ -559,9 +559,6 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
moodEntries: { enabled: true, fields: ['withWhom', 'notes'] }, moodEntries: { enabled: true, fields: ['withWhom', 'notes'] },
moodSettings: { enabled: false, fields: [] }, moodSettings: { enabled: false, fields: [] },
// ─── Kontext (legacy — now web-context, URL-crawl only) ──
kontextDoc: { enabled: true, fields: ['content'] },
// ─── User Context (profile hub) ────────────────────────── // ─── User Context (profile hub) ──────────────────────────
// Structured profile sections + freeform markdown. Everything // Structured profile sections + freeform markdown. Everything
// except the fixed id and interview progress is user-typed content. // except the fixed id and interview progress is user-typed content.
@ -690,8 +687,9 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
'livingOracleSnapshot', 'livingOracleSnapshot',
]), ]),
// Per-agent kontext documents — same schema as kontextDoc but keyed // Per-agent kontext documents — free-form markdown, keyed per agent.
// per agent. Content is free-form markdown. // 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'] }, agentKontextDocs: { enabled: true, fields: ['content'] },
// ─── Quiz ──────────────────────────────────────────────── // ─── Quiz ────────────────────────────────────────────────

View file

@ -1445,6 +1445,18 @@ db.version(57).stores({
formResponses: 'id, formId, status, submittedAt, _updatedAtIndex, [formId+status]', 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 Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module // toSyncName() and fromSyncName() are now derived from per-module

View file

@ -94,7 +94,6 @@ import { mailModuleConfig } from '$lib/modules/mail/module.config';
import { meditateModuleConfig } from '$lib/modules/meditate/module.config'; import { meditateModuleConfig } from '$lib/modules/meditate/module.config';
import { sleepModuleConfig } from '$lib/modules/sleep/module.config'; import { sleepModuleConfig } from '$lib/modules/sleep/module.config';
import { moodModuleConfig } from '$lib/modules/mood/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 { quizModuleConfig } from '$lib/modules/quiz/module.config';
import { profileModuleConfig } from '$lib/modules/profile/module.config'; import { profileModuleConfig } from '$lib/modules/profile/module.config';
import { libraryModuleConfig } from '$lib/modules/library/module.config'; import { libraryModuleConfig } from '$lib/modules/library/module.config';
@ -158,7 +157,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
meditateModuleConfig, meditateModuleConfig,
sleepModuleConfig, sleepModuleConfig,
moodModuleConfig, moodModuleConfig,
kontextModuleConfig,
quizModuleConfig, quizModuleConfig,
profileModuleConfig, profileModuleConfig,
libraryModuleConfig, libraryModuleConfig,

View file

@ -763,8 +763,8 @@ describe('applyServerChanges (Dexie integration)', () => {
it('bootstrap-twin race: local SYSTEM_BOOTSTRAP row + later server insert → no conflict, LWW wins', async () => { 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'. // 1. Client-side fallback creates an empty row stamped origin='system'.
// This is what `getOrCreateLocalDoc()` does in userContextStore / // This is what `getOrCreateLocalDoc()` does in userContextStore
// kontextStore when a write lands before the first sync pull. // when a write lands before the first sync pull.
const bootstrapActor = makeSystemActor(SYSTEM_BOOTSTRAP); const bootstrapActor = makeSystemActor(SYSTEM_BOOTSTRAP);
await runAsAsync(bootstrapActor, async () => { await runAsAsync(bootstrapActor, async () => {
await db.table('tasks').add({ await db.table('tasks').add({

View file

@ -149,7 +149,7 @@
} }
// ── Policy editor ─────────────────────────────────────── // ── 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']; const POLICY_CHOICES: PolicyDecision[] = ['auto', 'propose', 'deny'];
function policyLabel(c: PolicyDecision): string { function policyLabel(c: PolicyDecision): string {
return $_('ai-agents.list_view.policy_label_' + c); return $_('ai-agents.list_view.policy_label_' + c);

View file

@ -113,13 +113,7 @@
} }
// ── Key-Grant (server-side execution) ────────────────── // ── Key-Grant (server-side execution) ──────────────────
const ENCRYPTED_SERVER_TABLES = new Set([ const ENCRYPTED_SERVER_TABLES = new Set(['notes', 'tasks', 'events', 'journalEntries']);
'notes',
'tasks',
'events',
'journalEntries',
'kontextDoc',
]);
function hasEncryptedInputs(m: Mission): boolean { function hasEncryptedInputs(m: Mission): boolean {
return m.inputs.some((i) => ENCRYPTED_SERVER_TABLES.has(i.table)); return m.inputs.some((i) => ENCRYPTED_SERVER_TABLES.has(i.table));
} }

View file

@ -1,360 +0,0 @@
<!--
Web-Context — URL crawler tool.
Crawls web pages and appends the content to the user's profile freeform context.
-->
<script lang="ts">
import { LinkSimple, X } from '@mana/shared-icons';
import { crawlUrlViaApi, type CrawlMode } from './api';
import { requireAuth } from '$lib/auth/require-auth.svelte';
import { userContextStore } from '$lib/modules/profile/stores/user-context.svelte';
let importUrl = $state('');
let importMode = $state<CrawlMode>('single');
let importSummarize = $state(false);
let importing = $state(false);
let importPhase = $state<'idle' | 'crawling' | 'summarizing' | 'appending'>('idle');
let importElapsed = $state(0);
let importError = $state<string | null>(null);
let successMessage = $state<string | null>(null);
let importPhases = $derived.by(() => {
const steps: Array<{ key: 'crawling' | 'summarizing' | 'appending'; label: string }> = [
{
key: 'crawling',
label: importMode === 'deep' ? 'Website crawlen (bis 20 Seiten)' : 'Seite laden',
},
...(importSummarize ? [{ key: 'summarizing' as const, label: 'Mit KI zusammenfassen' }] : []),
{ key: 'appending', label: 'In Profil-Kontext speichern' },
];
const order = steps.map((s) => s.key);
const currentIdx = order.indexOf(importPhase as never);
return steps.map((s, i) => ({
key: s.key,
label: s.label,
active: currentIdx === i,
done: currentIdx > i,
}));
});
function reset() {
importUrl = '';
importMode = 'single';
importSummarize = false;
importPhase = 'idle';
importElapsed = 0;
importError = null;
}
async function handleImport(e: Event) {
e.preventDefault();
const trimmed = importUrl.trim();
if (!trimmed) return;
const ok = await requireAuth({
feature: 'web-context-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;
successMessage = null;
importPhase = 'crawling';
importElapsed = 0;
const started = performance.now();
const tick = setInterval(() => {
importElapsed = Math.floor((performance.now() - started) / 1000);
}, 250);
let phaseTimer: ReturnType<typeof setTimeout> | null = null;
if (importSummarize) {
const crawlBudgetMs = importMode === 'deep' ? 25_000 : 4_000;
phaseTimer = setTimeout(() => {
if (importing) importPhase = 'summarizing';
}, crawlBudgetMs);
}
try {
const result = await crawlUrlViaApi({
url: trimmed,
mode: importMode,
summarize: importSummarize,
});
if (phaseTimer) clearTimeout(phaseTimer);
importPhase = 'appending';
const header = `## ${result.title}\n\n_Quelle: ${result.sourceUrl}_\n\n`;
await userContextStore.appendFreeform(header + result.content);
successMessage = `"${result.title}" wurde deinem Profil-Kontext hinzugefügt (${result.pageCount} Seite${result.pageCount > 1 ? 'n' : ''})`;
reset();
} catch (err) {
importError = err instanceof Error ? err.message : 'Import fehlgeschlagen';
} finally {
if (phaseTimer) clearTimeout(phaseTimer);
clearInterval(tick);
importing = false;
}
}
</script>
<div class="app-view">
<header class="header">
<div class="header-icon">
<LinkSimple size={20} />
</div>
<div>
<h2 class="title">Web-Context</h2>
<p class="subtitle">Crawle Webseiten und speichere den Inhalt in deinem Profil-Kontext</p>
</div>
</header>
<form class="crawl-form" onsubmit={handleImport}>
<div class="url-row">
<input
type="url"
bind:value={importUrl}
required
placeholder="https://example.com/article"
disabled={importing}
class="url-input"
/>
<button type="submit" disabled={importing || !importUrl.trim()} class="url-submit">
{#if importing}
{importPhase === 'crawling'
? 'Crawle…'
: importPhase === 'summarizing'
? 'Fasse zusammen…'
: 'Speichere…'}
{:else}
Importieren
{/if}
</button>
</div>
<div class="url-opts">
<label class:disabled={importing}>
<input type="radio" bind:group={importMode} value="single" disabled={importing} />
Nur diese Seite
</label>
<label class:disabled={importing}>
<input type="radio" bind:group={importMode} value="deep" disabled={importing} />
Ganze Website (max. 20)
</label>
<span class="url-sep">·</span>
<label class:disabled={importing}>
<input type="checkbox" bind:checked={importSummarize} disabled={importing} />
Mit KI zusammenfassen
</label>
</div>
{#if importing || importPhase !== 'idle'}
<ol class="phase-list" aria-live="polite">
{#each importPhases as phase (phase.key)}
<li class="phase" class:active={phase.active} class:done={phase.done}>
<span class="phase-dot" aria-hidden="true">
{#if phase.done}
{:else if phase.active}
<span class="phase-spinner"></span>
{:else}
·
{/if}
</span>
<span class="phase-label">{phase.label}</span>
{#if phase.active}
<span class="phase-elapsed">{importElapsed}s</span>
{/if}
</li>
{/each}
</ol>
{/if}
{#if importError}
<p class="error">{importError}</p>
{/if}
</form>
{#if successMessage}
<div class="success">
<p>{successMessage}</p>
</div>
{/if}
<div class="info">
<p>Importierte Inhalte werden im <strong>Freitext-Tab</strong> deines Profils gespeichert.</p>
<p>Der Crawler respektiert robots.txt und nutzt Rate-Limits.</p>
</div>
</div>
<style>
.app-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
}
.header {
display: flex;
gap: 0.75rem;
align-items: center;
}
.header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
flex-shrink: 0;
}
.title {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.subtitle {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.crawl-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
background: hsl(var(--color-card));
}
.url-row {
display: flex;
align-items: stretch;
gap: 0.375rem;
}
.url-input {
flex: 1;
min-width: 0;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.875rem;
outline: none;
}
.url-input:focus {
border-color: hsl(var(--color-ring));
}
.url-submit {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.url-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.url-opts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.url-opts label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
cursor: pointer;
}
.url-opts label.disabled {
opacity: 0.5;
}
.url-sep {
opacity: 0.4;
}
.phase-list {
list-style: none;
margin: 0;
padding: 0.25rem 0 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.phase {
display: grid;
grid-template-columns: 1rem 1fr auto;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.phase.active {
color: hsl(var(--color-primary));
font-weight: 500;
}
.phase.done {
color: hsl(var(--color-success, var(--color-primary)));
}
.phase-dot {
width: 1rem;
height: 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
.phase-spinner {
width: 0.75rem;
height: 0.75rem;
border: 1.5px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.phase-elapsed {
font-variant-numeric: tabular-nums;
font-size: 0.75rem;
opacity: 0.7;
}
.error {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-destructive, 0 84% 60%));
}
.success {
padding: 0.75rem 1rem;
border: 1px solid hsl(142 71% 45% / 0.3);
border-radius: 0.5rem;
background: hsl(142 71% 45% / 0.08);
color: hsl(142 71% 45%);
font-size: 0.8125rem;
}
.success p {
margin: 0;
}
.info {
margin-top: auto;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.info p {
margin: 0.25rem 0;
}
</style>

View file

@ -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<LocalKontextDoc>('kontextDoc');

View file

@ -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';

View file

@ -1,6 +0,0 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const kontextModuleConfig: ModuleConfig = {
appId: 'kontext',
tables: [{ name: 'kontextDoc' }],
};

View file

@ -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:<userId>` 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<LocalKontextDoc, string>('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
);
}

View file

@ -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:<userId>` 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<LocalKontextDoc | undefined> {
const rows = await scopedTable<LocalKontextDoc, string>('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<LocalKontextDoc> {
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<void> {
const row = await getOrCreateLocalDoc();
const diff: Partial<LocalKontextDoc> = {
content,
};
await encryptRecord('kontextDoc', diff);
await kontextDocTable.update(row.id, diff);
},
async appendContent(chunk: string): Promise<void> {
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}`);
},
};

View file

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

View file

@ -10,11 +10,13 @@
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui'; import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte'; 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 FloatingInputBar from '$lib/components/FloatingInputBar.svelte';
import AgentDot from '$lib/components/ai/AgentDot.svelte'; import AgentDot from '$lib/components/ai/AgentDot.svelte';
import ScopeEmptyState from '$lib/components/workbench/ScopeEmptyState.svelte'; import ScopeEmptyState from '$lib/components/workbench/ScopeEmptyState.svelte';
import { hasActiveSceneScope } from '$lib/stores/scene-scope.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(); let { navigate, goBack, params }: ViewProps = $props();
@ -100,6 +102,60 @@
await notesStore.togglePin(id); await notesStore.togglePin(id);
} }
// ─── URL Import ──────────────────────────────────────────
let urlImportOpen = $state(false);
let importUrl = $state('');
let importMode = $state<CrawlMode>('single');
let importSummarize = $state(false);
let importing = $state(false);
let importError = $state<string | null>(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<Note>(); const ctxMenu = useItemContextMenu<Note>();
let ctxMenuItems = $derived<ContextMenuItem[]>( let ctxMenuItems = $derived<ContextMenuItem[]>(
@ -123,6 +179,17 @@
if (target) notesStore.togglePin(target.id); 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: 'div', label: '', type: 'divider' as const },
{ {
id: 'delete', id: 'delete',
@ -140,6 +207,67 @@
</script> </script>
<div class="app-view"> <div class="app-view">
<!-- URL import: collapsed pill, expands to inline form on click -->
<div class="url-import">
{#if !urlImportOpen}
<button
type="button"
class="url-import-toggle"
onclick={() => (urlImportOpen = true)}
aria-label="Notiz aus URL erstellen"
>
<LinkSimple size={14} />
<span>Aus URL importieren</span>
</button>
{:else}
<form class="url-import-form" onsubmit={handleImport}>
<div class="url-import-row">
<input
type="url"
bind:value={importUrl}
required
placeholder="https://example.com/article"
disabled={importing}
class="url-import-input"
/>
<button type="submit" class="url-import-submit" disabled={importing || !importUrl.trim()}>
{importing ? 'Lade…' : 'Importieren'}
</button>
<button
type="button"
class="url-import-cancel"
onclick={() => {
urlImportOpen = false;
resetImport();
}}
disabled={importing}
aria-label="Abbrechen"
>
×
</button>
</div>
<div class="url-import-opts">
<label class:disabled={importing}>
<input type="radio" bind:group={importMode} value="single" disabled={importing} />
Nur diese Seite
</label>
<label class:disabled={importing}>
<input type="radio" bind:group={importMode} value="deep" disabled={importing} />
Ganze Website (max. 20)
</label>
<span class="sep">·</span>
<label class:disabled={importing}>
<input type="checkbox" bind:checked={importSummarize} disabled={importing} />
KI-Zusammenfassung
</label>
</div>
{#if importError}
<p class="url-import-error">{importError}</p>
{/if}
</form>
{/if}
</div>
<!-- Search --> <!-- Search -->
{#if notes.length > 5} {#if notes.length > 5}
<input class="search-input" type="text" placeholder="Suchen..." bind:value={searchQuery} /> <input class="search-input" type="text" placeholder="Suchen..." bind:value={searchQuery} />
@ -192,6 +320,14 @@
<div class="note-top"> <div class="note-top">
<span class="note-title">{note.title || 'Unbenannt'}</span> <span class="note-title">{note.title || 'Unbenannt'}</span>
<AgentDot record={note} /> <AgentDot record={note} />
{#if note.isSpaceContext}
<span
class="space-context-badge"
title="Space-Kontext — Quelle für AI-Referenzen in diesem Space"
>
<Scroll size={12} />
</span>
{/if}
{#if note.isPinned}<span class="pin">&#x1f4cc;</span>{/if} {#if note.isPinned}<span class="pin">&#x1f4cc;</span>{/if}
</div> </div>
{#if note.content} {#if note.content}
@ -250,6 +386,116 @@
position: relative; 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 { .search-input {
padding: 0.3rem 0.5rem; padding: 0.3rem 0.5rem;
border-radius: 0.375rem; border-radius: 0.375rem;

View file

@ -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 * Crawls a URL via mana-crawler, optionally summarises the result with
* crawler + LLM wrapper). Only the kontext singleton consumes it. * 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'; import { authStore } from '$lib/stores/auth.svelte';
@ -25,10 +27,10 @@ export interface ImportResponse {
pageCount: number; pageCount: number;
} }
export async function crawlUrlViaApi(input: ImportInput): Promise<ImportResponse> { export async function crawlUrl(input: ImportInput): Promise<ImportResponse> {
const token = await authStore.getValidToken(); const token = await authStore.getValidToken();
if (!token) throw new Error('not authenticated'); 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View file

@ -35,6 +35,7 @@ export function toNote(local: LocalNote): Note {
transcriptModel: local.transcriptModel ?? null, transcriptModel: local.transcriptModel ?? null,
isPinned: local.isPinned, isPinned: local.isPinned,
isArchived: local.isArchived, isArchived: local.isArchived,
isSpaceContext: local.isSpaceContext ?? false,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: deriveUpdatedAt(local), updatedAt: deriveUpdatedAt(local),
}; };
@ -63,6 +64,25 @@ export function useAllNotes() {
}, [] as Note[]); }, [] 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<LocalNote, string>('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. */ /** Single note by id, decrypted. Used by detail views. */
export function useNote(id: string) { export function useNote(id: string) {
return useScopedLiveQuery( return useScopedLiveQuery(

View file

@ -121,4 +121,41 @@ export const notesStore = {
isArchived: true, 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<void> {
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 });
}
},
}; };

View file

@ -16,6 +16,15 @@ export interface LocalNote extends BaseRecord {
transcriptModel?: string | null; transcriptModel?: string | null;
isPinned: boolean; isPinned: boolean;
isArchived: 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 ───────────────────────────────────────── // ─── Domain Types ─────────────────────────────────────────
@ -28,6 +37,7 @@ export interface Note {
transcriptModel: string | null; transcriptModel: string | null;
isPinned: boolean; isPinned: boolean;
isArchived: boolean; isArchived: boolean;
isSpaceContext: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View file

@ -7,7 +7,7 @@
import { useUserContext } from './queries'; import { useUserContext } from './queries';
import { userContextStore } from './stores/user-context.svelte'; import { userContextStore } from './stores/user-context.svelte';
import { PencilSimple, Eye, LinkSimple, X } from '@mana/shared-icons'; 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 { requireAuth } from '$lib/auth/require-auth.svelte';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
@ -116,7 +116,7 @@
); );
} }
try { try {
const result = await crawlUrlViaApi({ const result = await crawlUrl({
url: trimmed, url: trimmed,
mode: importMode, mode: importMode,
summarize: importSummarize, summarize: importSummarize,

View file

@ -8,7 +8,7 @@
- note → searchable list of notes - note → searchable list of notes
- library → searchable list of library entries - library → searchable list of library entries
- url → freeform URL input + optional context note - 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 - goal → searchable list of goals
- me-image → searchable list of profile reference images - me-image → searchable list of profile reference images
@ -18,9 +18,8 @@
<script lang="ts"> <script lang="ts">
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { useAllArticles } from '$lib/modules/articles/queries'; import { useAllArticles } from '$lib/modules/articles/queries';
import { useAllNotes } from '$lib/modules/notes/queries'; import { useAllNotes, useSpaceContextNote } from '$lib/modules/notes/queries';
import { useAllEntries as useAllLibraryEntries } from '$lib/modules/library/queries'; import { useAllEntries as useAllLibraryEntries } from '$lib/modules/library/queries';
import { useKontextDoc } from '$lib/modules/kontext/queries';
import { useAllMeImages } from '$lib/modules/profile/queries'; import { useAllMeImages } from '$lib/modules/profile/queries';
import { useAllGoals } from '$lib/companion/goals/queries'; import { useAllGoals } from '$lib/companion/goals/queries';
import ReferenceChip from './ReferenceChip.svelte'; import ReferenceChip from './ReferenceChip.svelte';
@ -36,8 +35,8 @@
'me-image', 'me-image',
]; ];
const MAX_REFERENCES = 6; const MAX_REFERENCES = 6;
/** Sentinel targetId for the kontext-singleton — the resolver doesn't /** Sentinel targetId — the resolver finds the flagged note by scope-scan,
* use it, but a non-null id keeps the de-dupe + chip-key logic uniform. */ * not by id, but a non-null id keeps the de-dupe + chip-key logic uniform. */
const KONTEXT_SINGLETON_ID = 'kontext:singleton'; const KONTEXT_SINGLETON_ID = 'kontext:singleton';
let { let {
@ -51,7 +50,7 @@
const articles$ = useAllArticles(); const articles$ = useAllArticles();
const notes$ = useAllNotes(); const notes$ = useAllNotes();
const library$ = useAllLibraryEntries(); const library$ = useAllLibraryEntries();
const kontext$ = useKontextDoc(); const kontext$ = useSpaceContextNote();
const meImages$ = useAllMeImages(); const meImages$ = useAllMeImages();
const goals$ = useAllGoals(); const goals$ = useAllGoals();
@ -374,7 +373,7 @@
<div class="search"> <div class="search">
{#if !kontextDoc} {#if !kontextDoc}
<p class="muted small"> <p class="muted small">
{$_('writing.reference_picker.kontext_empty_pre')}<a href="/kontext">/kontext</a>{$_( {$_('writing.reference_picker.kontext_empty_pre')}<a href="/notes">/notes</a>{$_(
'writing.reference_picker.kontext_empty_post' 'writing.reference_picker.kontext_empty_post'
)} )}
</p> </p>

View file

@ -41,10 +41,6 @@ vi.mock('$lib/modules/library/queries', () => ({
...local, ...local,
})), })),
})); }));
vi.mock('$lib/modules/kontext/queries', () => ({
toKontextDoc: vi.fn((local) => ({ ...local })),
}));
import { scopedGet, scopedForModule } from '$lib/data/scope'; import { scopedGet, scopedForModule } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
@ -242,14 +238,25 @@ describe('resolveReference - url', () => {
}); });
}); });
describe('resolveReference - kontext (singleton)', () => { describe('resolveReference - kontext (Space-Kontext Note)', () => {
it('reads the singleton via scopedForModule and ignores the targetId', async () => { it('reads the isSpaceContext-flagged Note via scopedForModule and ignores the targetId', async () => {
const toArrayMock = vi const toArrayMock = vi.fn().mockResolvedValue([
.fn() { id: 'note-1', title: 'Random', content: 'no flag', isSpaceContext: false },
.mockResolvedValue([{ id: 'kontext-uuid', content: 'mein laufender kontext' }]); {
id: 'note-2',
title: 'Brand-Profil',
content: 'mein laufender kontext',
isSpaceContext: true,
},
]);
mockScopedForModule.mockReturnValue({ toArray: toArrayMock }); mockScopedForModule.mockReturnValue({ toArray: toArrayMock });
mockDecryptRecords.mockResolvedValue([ mockDecryptRecords.mockResolvedValue([
{ id: 'kontext-uuid', content: 'mein laufender kontext' }, {
id: 'note-2',
title: 'Brand-Profil',
content: 'mein laufender kontext',
isSpaceContext: true,
},
]); ]);
const result = await resolveReference({ const result = await resolveReference({
@ -257,16 +264,30 @@ describe('resolveReference - kontext (singleton)', () => {
targetId: 'irrelevant', targetId: 'irrelevant',
note: null, note: null,
}); });
expect(result?.sourceLabel).toBe('Kontext-Dokument des Spaces'); expect(result?.sourceLabel).toBe('Space-Kontext (Notiz)');
expect(result?.title).toBe('Brand-Profil');
expect(result?.content).toBe('mein laufender kontext'); expect(result?.content).toBe('mein laufender kontext');
}); });
it('skips deleted singleton rows', async () => { it('returns null when no Note is flagged as Space-Kontext', async () => {
const toArrayMock = vi const toArrayMock = vi
.fn() .fn()
.mockResolvedValue([ .mockResolvedValue([{ id: 'note-1', title: 'Random', content: 'no flag' }]);
{ id: 'kontext-uuid', content: 'old', deletedAt: '2026-01-01T00:00:00Z' }, mockScopedForModule.mockReturnValue({ toArray: toArrayMock });
]); const result = await resolveReference({ kind: 'kontext', targetId: 'x', note: null });
expect(result).toBeNull();
});
it('skips deleted Space-Kontext Notes', async () => {
const toArrayMock = vi.fn().mockResolvedValue([
{
id: 'note-1',
title: 'old context',
content: 'old',
isSpaceContext: true,
deletedAt: '2026-01-01T00:00:00Z',
},
]);
mockScopedForModule.mockReturnValue({ toArray: toArrayMock }); mockScopedForModule.mockReturnValue({ toArray: toArrayMock });
const result = await resolveReference({ kind: 'kontext', targetId: 'x', note: null }); const result = await resolveReference({ kind: 'kontext', targetId: 'x', note: null });
expect(result).toBeNull(); expect(result).toBeNull();

View file

@ -21,11 +21,9 @@ import { db } from '$lib/data/database';
import { toArticle } from '$lib/modules/articles/queries'; import { toArticle } from '$lib/modules/articles/queries';
import { toNote } from '$lib/modules/notes/queries'; import { toNote } from '$lib/modules/notes/queries';
import { toLibraryEntry } from '$lib/modules/library/queries'; import { toLibraryEntry } from '$lib/modules/library/queries';
import { toKontextDoc } from '$lib/modules/kontext/queries';
import type { LocalArticle } from '$lib/modules/articles/types'; import type { LocalArticle } from '$lib/modules/articles/types';
import type { LocalNote } from '$lib/modules/notes/types'; import type { LocalNote } from '$lib/modules/notes/types';
import type { LocalLibraryEntry } from '$lib/modules/library/types'; import type { LocalLibraryEntry } from '$lib/modules/library/types';
import type { LocalKontextDoc } from '$lib/modules/kontext/types';
import type { LocalMeImage } from '$lib/modules/profile/types'; import type { LocalMeImage } from '$lib/modules/profile/types';
import type { LocalGoal } from '$lib/companion/goals/types'; import type { LocalGoal } from '$lib/companion/goals/types';
import type { DraftReference } from '../types'; import type { DraftReference } from '../types';
@ -134,23 +132,23 @@ function resolveUrl(ref: DraftReference): Omit<ResolvedReference, 'kind' | 'note
} }
/** /**
* Kontext is a per-space singleton the picker stores a sentinel * Kontext = the active Space's standing-context Note (the one with
* targetId ('singleton'), and the resolver ignores it and picks the * `isSpaceContext: true`). Picker stores a sentinel targetId since the
* first non-deleted row scoped to the active space. Legacy rows use * resolver ignores it and finds the flagged note via scope-scan.
* id='singleton' explicitly; fresh rows use a uuid but are still * Replaces the retired per-space `kontextDoc` singleton table same
* singular per space. * concept, regular Note as the storage.
*/ */
async function resolveKontext(): Promise<Omit<ResolvedReference, 'kind' | 'note'> | null> { async function resolveKontext(): Promise<Omit<ResolvedReference, 'kind' | 'note'> | null> {
const rows = await scopedForModule<LocalKontextDoc, string>('kontext', 'kontextDoc').toArray(); const rows = await scopedForModule<LocalNote, string>('notes', 'notes').toArray();
const local = rows.find((r) => !r.deletedAt); const local = rows.find((r) => !r.deletedAt && r.isSpaceContext === true);
if (!local) return null; if (!local) return null;
const [decrypted] = await decryptRecords('kontextDoc', [local]); const [decrypted] = await decryptRecords('notes', [local]);
if (!decrypted) return null; if (!decrypted) return null;
const doc = toKontextDoc(decrypted); const note = toNote(decrypted);
return { return {
sourceLabel: 'Kontext-Dokument des Spaces', sourceLabel: 'Space-Kontext (Notiz)',
title: 'Kontext', title: note.title || 'Kontext',
content: truncate(doc.content ?? ''), content: truncate(note.content ?? ''),
}; };
} }

View file

@ -1,12 +0,0 @@
<script lang="ts">
import KontextView from '$lib/modules/kontext/KontextView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Web-Context - Mana</title>
</svelte:head>
<RoutePage appId="kontext">
<KontextView />
</RoutePage>

View file

@ -50,8 +50,8 @@ export enum CreditOperationType {
// Traces - City guide generation // Traces - City guide generation
AI_GUIDE_GENERATION = 'ai_guide_generation', AI_GUIDE_GENERATION = 'ai_guide_generation',
// Context - AI text generation // Notes - URL crawl + optional summary into a Note
AI_CONTEXT_GENERATION = 'ai_context_generation', NOTES_IMPORT_URL = 'notes_import_url',
// General AI features // General AI features
AI_SMART_SCHEDULING = 'ai_smart_scheduling', AI_SMART_SCHEDULING = 'ai_smart_scheduling',
@ -104,7 +104,7 @@ export const CREDIT_COSTS: Record<CreditOperationType, number> = {
[CreditOperationType.AI_PLANT_ANALYSIS]: 2, [CreditOperationType.AI_PLANT_ANALYSIS]: 2,
[CreditOperationType.AI_GUIDE_GENERATION]: 5, [CreditOperationType.AI_GUIDE_GENERATION]: 5,
[CreditOperationType.AI_CONTEXT_GENERATION]: 2, [CreditOperationType.NOTES_IMPORT_URL]: 1,
[CreditOperationType.AI_SMART_SCHEDULING]: 2, [CreditOperationType.AI_SMART_SCHEDULING]: 2,
[CreditOperationType.AI_SUGGESTIONS]: 2, [CreditOperationType.AI_SUGGESTIONS]: 2,
@ -259,12 +259,12 @@ export const OPERATION_METADATA: Record<CreditOperationType, OperationMetadata>
app: 'traces', app: 'traces',
}, },
// Context // Notes
[CreditOperationType.AI_CONTEXT_GENERATION]: { [CreditOperationType.NOTES_IMPORT_URL]: {
name: 'AI Text Generation', name: 'URL Import (Note)',
description: 'Generate or transform text with AI', description: 'Crawl a URL and create a Markdown Note (optional AI summary)',
category: CreditCategory.AI, category: CreditCategory.AI,
app: 'context', app: 'notes',
}, },
// General AI // General AI

View file

@ -35,9 +35,9 @@ export const SYSTEM_STREAM = 'system:stream';
export const SYSTEM_MISSION_RUNNER = 'system:mission-runner'; export const SYSTEM_MISSION_RUNNER = 'system:mission-runner';
/** /**
* Client-side singleton bootstrap. Stamped on the rare race-window * Client-side singleton bootstrap. Stamped on the rare race-window
* `getOrCreateLocalDoc()` insert in `userContextStore` / `kontextStore` * `getOrCreateLocalDoc()` insert in `userContextStore` a structural
* a structural twin of mana-auth's server-side bootstrap (which uses * twin of mana-auth's server-side bootstrap (which uses the
* the `'system:bootstrap'` principalId on the wire). Maps to * `'system:bootstrap'` principalId on the wire). Maps to
* `origin='system'` via `originFromActor`, so the conflict-gate exempts * `origin='system'` via `originFromActor`, so the conflict-gate exempts
* it from the user-write codepath. * it from the user-write codepath.
*/ */

View file

@ -153,7 +153,6 @@ const track = {
contacts: createModuleTracker('contacts'), contacts: createModuleTracker('contacts'),
cards: createModuleTracker('cards'), cards: createModuleTracker('cards'),
mana: createModuleTracker('mana'), mana: createModuleTracker('mana'),
context: createModuleTracker('context'),
skilltree: createModuleTracker('skilltree'), skilltree: createModuleTracker('skilltree'),
food: createModuleTracker('food'), food: createModuleTracker('food'),
plants: createModuleTracker('plants'), plants: createModuleTracker('plants'),
@ -352,18 +351,6 @@ export const ManaEvents = {
track.mana('feature_blocked_by_auth', params), 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 * SkillTree App Events
*/ */

View file

@ -47,7 +47,6 @@ import {
assertSpaceIsDeletable, assertSpaceIsDeletable,
createPersonalSpaceFor, createPersonalSpaceFor,
} from '../spaces'; } from '../spaces';
import { bootstrapSpaceSingletons } from '../services/bootstrap-singletons';
// Re-export so existing imports (`import { TRUSTED_ORIGINS } from './better-auth.config'`) // Re-export so existing imports (`import { TRUSTED_ORIGINS } from './better-auth.config'`)
// keep working. New code should import from './sso-origins' directly. // keep working. New code should import from './sso-origins' directly.
@ -98,10 +97,10 @@ export interface BetterAuthWebAuthnOptions {
* Create Better Auth instance * Create Better Auth instance
* *
* @param databaseUrl - PostgreSQL connection URL for the auth DB * @param databaseUrl - PostgreSQL connection URL for the auth DB
* @param syncDatabaseUrl - PostgreSQL connection URL for `mana_sync`. The * @param syncDatabaseUrl - PostgreSQL connection URL for `mana_sync`. Held
* personal-space + organization hooks bootstrap per-Space singletons * for use by the per-user `userContext` bootstrap; currently no
* into `sync_changes` so fresh clients pull the row instead of racing * per-Space singletons are written here (the kontextDoc that used to
* on a local insert. See `bootstrapSpaceSingletons`. * live here was retired in the Option-B cleanup).
* @param webauthn - WebAuthn settings for the passkey plugin * @param webauthn - WebAuthn settings for the passkey plugin
* @returns Better Auth instance * @returns Better Auth instance
*/ */
@ -272,24 +271,6 @@ export function createBetterAuth(
name: user.name, name: user.name,
accessTier: (user as { accessTier?: string | null }).accessTier, 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 * Spaces enforce that every organization carries a valid
* `metadata.type` (the Space type), and block deletion of the * `metadata.type` (the Space type), and block deletion of the
* user's personal space. After-create bootstraps per-Space * user's personal space. The per-Space `kontextDoc` singleton
* singletons (currently `kontextDoc`) into mana_sync so fresh * that used to be bootstrapped here was retired in favour of
* clients pull the row instead of racing on a local insert. * the user-driven `notes.isSpaceContext` flag (Option B
* Personal-space gets the same bootstrap, but from * cleanup), so the after-create hook is currently empty
* `databaseHooks.user.create.after` because Better Auth's * kept as a hook anchor for future per-Space bootstrap needs.
* `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.
*/ */
organizationHooks: { organizationHooks: {
beforeCreateOrganization: async ({ organization }) => { beforeCreateOrganization: async ({ organization }) => {
assertValidSpaceMetadataForCreate(organization.metadata); 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 }) => { beforeDeleteOrganization: async ({ organization }) => {
assertSpaceIsDeletable(organization.metadata); assertSpaceIsDeletable(organization.metadata);
}, },

View file

@ -149,10 +149,10 @@ app.route('/api/v1/me/ai-mission-grant', createAiMissionGrantRoutes(missionGrant
app.route('/api/v1/me/onboarding', createOnboardingRoutes(db)); app.route('/api/v1/me/onboarding', createOnboardingRoutes(db));
// ─── Singleton Bootstrap ──────────────────────────────────── // ─── Singleton Bootstrap ────────────────────────────────────
// Idempotent reconciliation endpoint for per-user + per-Space sync // Idempotent reconciliation endpoint for the per-user `userContext`
// singletons (userContext, kontextDoc). Webapp boot calls this once; // singleton. Webapp boot calls this once; the signup-time hook remains
// signup-time hooks remain the happy path. See // the happy path. See docs/plans/sync-field-meta-overhaul.md and
// docs/plans/sync-field-meta-overhaul.md and routes/me-bootstrap.ts. // routes/me-bootstrap.ts.
app.route('/api/v1/me/bootstrap-singletons', createMeBootstrapRoutes(db, config.syncDatabaseUrl)); app.route('/api/v1/me/bootstrap-singletons', createMeBootstrapRoutes(db, config.syncDatabaseUrl));
// ─── Settings ────────────────────────────────────────────── // ─── Settings ──────────────────────────────────────────────

View file

@ -2,34 +2,34 @@
* Singleton bootstrap endpoint. * Singleton bootstrap endpoint.
* *
* `POST /api/v1/me/bootstrap-singletons` idempotently provisions the * `POST /api/v1/me/bootstrap-singletons` idempotently provisions the
* per-user `userContext` singleton and the per-Space `kontextDoc` for * per-user `userContext` singleton. Called once per webapp boot as a
* every Space the caller is a member of. Called once per webapp boot * reconciliation belt-and-suspenders for the signup-time hook
* as a reconciliation belt-and-suspenders for the signup-time hooks * (databaseHooks.user.create.after).
* (databaseHooks.user.create.after + organizationHooks.afterCreateOrganization).
* *
* 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 * fire-and-forget a transient mana_sync outage during signup leaves
* the user with no singleton and no signal that anything is wrong. The * the user with no singleton and no signal that anything is wrong. The
* boot-time endpoint converges to the right state on every load. * 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. * harmless.
* *
* The endpoint is deliberately simple: no body, no parameters. The * The endpoint is deliberately simple: no body, no parameters. The
* caller's identity (and thus the userId + space-membership list) * caller's identity (and thus the userId) comes from the JWT.
* 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 { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import postgres from 'postgres'; import postgres from 'postgres';
import { logger } from '@mana/shared-hono'; import { logger } from '@mana/shared-hono';
import type { AuthUser } from '../middleware/jwt-auth'; import type { AuthUser } from '../middleware/jwt-auth';
import type { Database } from '../db/connection'; import type { Database } from '../db/connection';
import { members } from '../db/schema/organizations'; import { bootstrapUserSingletons } from '../services/bootstrap-singletons';
import {
bootstrapUserSingletons,
bootstrapSpaceSingletons,
} from '../services/bootstrap-singletons';
export interface BootstrapResponse { export interface BootstrapResponse {
ok: true; ok: true;
@ -40,7 +40,7 @@ export interface BootstrapResponse {
} }
export function createMeBootstrapRoutes( export function createMeBootstrapRoutes(
db: Database, _db: Database,
syncDatabaseUrl: string syncDatabaseUrl: string
): Hono<{ Variables: { user: AuthUser } }> { ): Hono<{ Variables: { user: AuthUser } }> {
// Lazy module-scoped postgres pool. Mirrors routes/auth.ts and // 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); 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); return c.json(result);
}); });
} }

View file

@ -1,28 +1,27 @@
/** /**
* Server-side singleton bootstrap. * Server-side singleton bootstrap.
* *
* On first user-creation and Space-creation, write the singleton records * On first user-creation, write the singleton records that the webapp
* that the webapp would otherwise create on demand via `ensureDoc()` / * would otherwise create on demand via `ensureDoc()` /
* `getOrCreateLocalDoc()`. This makes the bootstrap deterministic every * `getOrCreateLocalDoc()`. This makes the bootstrap deterministic
* fresh client pulls the singleton from mana-sync instead of racing on a * every fresh client pulls the singleton from mana-sync instead of
* local insert that the next pull would clobber. * racing on a local insert that the next pull would clobber.
* *
* Currently bootstrapped: * Currently bootstrapped:
* - `userContext` per-user. The structured profile + freeform markdown * - `userContext` per-user. The structured profile + freeform markdown
* blob keyed by `id='singleton'`. Default shape mirrors the webapp's * blob keyed by `id='singleton'`. Default shape mirrors the webapp's
* `emptyUserContext()` factory in `profile/types.ts`. * `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 * `sync_changes` before inserting if a row matching the singleton's
* scope already exists, the call is a no-op. This makes the bootstrap * scope already exists, the call is a no-op. This makes the bootstrap
* safe to run from multiple sources without producing duplicate rows: * safe to run from multiple sources without producing duplicate rows:
* - signup-time hooks (databaseHooks.user.create.after, * - signup-time hook (databaseHooks.user.create.after) fires on the
* organizationHooks.afterCreateOrganization) fire on the happy * happy path
* path
* - boot-time endpoint (POST /api/v1/me/bootstrap-singletons) fires * - boot-time endpoint (POST /api/v1/me/bootstrap-singletons) fires
* on every webapp boot as a reconciliation belt-and-suspenders * on every webapp boot as a reconciliation belt-and-suspenders
* *
@ -88,19 +87,6 @@ function emptyUserContextData(userId: string): Record<string, unknown> {
}; };
} }
/**
* 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<string, unknown> {
return {
id: crypto.randomUUID(),
content: '',
};
}
/** /**
* Insert the per-user singletons into mana_sync.sync_changes. Idempotent * Insert the per-user singletons into mana_sync.sync_changes. Idempotent
* skips the insert if a row for `(userContext, 'singleton', userId)` * skips the insert if a row for `(userContext, 'singleton', userId)`
@ -149,62 +135,3 @@ export async function bootstrapUserSingletons(
`; `;
return true; 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<typeof postgres>
): Promise<boolean> {
if (!spaceId) throw new Error('bootstrapSpaceSingletons: empty spaceId');
if (!ownerUserId) throw new Error('bootstrapSpaceSingletons: empty ownerUserId');
const existing = await syncSql<Array<{ exists: number }>>`
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;
}