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

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

View file

@ -175,18 +175,6 @@ export const MODULE_HELP: Record<string, ModuleHelp> = {
'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.',

View file

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

View file

@ -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<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) ──────────
interface UserContextLike {
@ -155,35 +143,25 @@ const notesIndexer: InputIndexer = async () => {
const all = await db.table<NoteLike>('notes').toArray();
const visible = all.filter((n) => !n.deletedAt);
const decrypted = await decryptRecords('notes', visible);
return decrypted
.map<InputCandidate>((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:<userId>` 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<KontextDocLike, string>('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<InputCandidate>((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);

View file

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

View file

@ -559,9 +559,6 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
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<string, EncryptionConfig> = {
'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 ────────────────────────────────────────────────

View file

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

View file

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

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 () => {
// 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({

View file

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

View file

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

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 { 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<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>();
let ctxMenuItems = $derived<ContextMenuItem[]>(
@ -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 @@
</script>
<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 -->
{#if notes.length > 5}
<input class="search-input" type="text" placeholder="Suchen..." bind:value={searchQuery} />
@ -192,6 +320,14 @@
<div class="note-top">
<span class="note-title">{note.title || 'Unbenannt'}</span>
<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}
</div>
{#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;

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
* 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<ImportResponse> {
export async function crawlUrl(input: ImportInput): Promise<ImportResponse> {
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',

View file

@ -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<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. */
export function useNote(id: string) {
return useScopedLiveQuery(

View file

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

View file

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

View file

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

View file

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

View file

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