feat(ai-scope): wire filterByScope into list_tasks/contacts/events + note tag UI

All major list-returning auto-tools now filter by the ambient agent
scope: list_notes (already done), list_tasks (via taskTagTable),
get_contacts (via contactTagOps), get_todays_events (via eventTagOps).
Untagged records pass through (globally visible); tagged records are
only returned when at least one tag matches the agent's scopeTagIds.

Notes detail view (/notes/[id]) grows a TagSelector widget between
the content textarea and the color picker, powered by the new
noteTagOps junction. Users can manually tag notes to scope them to
specific agents — complements the AI's add_tag_to_note tool.

Also: todo/stores/tags.svelte.ts created (taskLabelOps wrapper around
the existing taskLabels junction for the scope filter).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-16 15:06:11 +02:00
parent ed01d24f2d
commit c412508b95
5 changed files with 70 additions and 4 deletions

View file

@ -4,8 +4,10 @@
import type { ModuleTool } from '$lib/data/tools/types';
import { eventsStore } from './stores/events.svelte';
import { eventTagOps } from './stores/tags.svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { filterByScope } from '$lib/data/ai/scope-context';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
export const calendarTools: ModuleTool[] = [
@ -65,7 +67,10 @@ export const calendarTools: ModuleTool[] = [
(b) => !b.deletedAt && b.type === 'event' && b.sourceModule === 'calendar'
);
const decrypted = await decryptRecords<LocalTimeBlock>('timeBlocks', eventBlocks);
const events = decrypted
const scoped = await filterByScope(decrypted, async (b) =>
b.sourceId ? eventTagOps.getTagIds(b.sourceId) : []
);
const events = scoped
.sort((a, b) => (a.startDate as string).localeCompare(b.startDate as string))
.map((b) => ({
id: b.sourceId,

View file

@ -1,7 +1,9 @@
import type { ModuleTool } from '$lib/data/tools/types';
import { contactsStore } from './stores/contacts.svelte';
import { contactTable } from './collections';
import { contactTagOps } from './stores/tags.svelte';
import { decryptRecords } from '$lib/data/crypto';
import { filterByScope } from '$lib/data/ai/scope-context';
import { toContact } from './queries';
import type { LocalContact } from './types';
@ -44,7 +46,8 @@ export const contactsTools: ModuleTool[] = [
const all = await contactTable.toArray();
const active = all.filter((c) => !c.deletedAt && !c.isArchived);
const decrypted = await decryptRecords<LocalContact>('contacts', active);
const contacts = decrypted.map(toContact);
const scoped = await filterByScope(decrypted, async (c) => contactTagOps.getTagIds(c.id));
const contacts = scoped.map(toContact);
return {
success: true,
data: contacts.map((c) => ({

View file

@ -0,0 +1,20 @@
/**
* Todo Tags Uses shared global tags via taskLabels junction table.
* Note: the junction uses 'labelId' (not 'tagId') for historical reasons.
*/
import { db } from '$lib/data/database';
import { createTagLinkOps } from '@mana/shared-stores';
export {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
} from '@mana/shared-stores';
export const taskLabelOps = createTagLinkOps({
table: () => db.table('taskLabels'),
entityIdField: 'taskId',
});

View file

@ -4,9 +4,10 @@
import type { ModuleTool } from '$lib/data/tools/types';
import { tasksStore } from './stores/tasks.svelte';
import { taskTable } from './collections';
import { taskTable, taskTagTable } from './collections';
import { toTask, getTaskStats } from './queries';
import { decryptRecords } from '$lib/data/crypto';
import { filterByScope } from '$lib/data/ai/scope-context';
import type { LocalTask } from './types';
export const todoTools: ModuleTool[] = [
@ -94,7 +95,11 @@ export const todoTools: ModuleTool[] = [
const all = await taskTable.toArray();
const active = all.filter((t) => !t.deletedAt);
const decrypted = await decryptRecords<LocalTask>('tasks', active);
const tasks = decrypted.map(toTask);
const scoped = await filterByScope(decrypted, async (t) => {
const links = await taskTagTable.where('taskId').equals(t.id).toArray();
return links.filter((l) => !l.deletedAt).map((l) => l.tagId);
});
const tasks = scoped.map(toTask);
const filter = (params.filter as string) ?? 'open';
const today = new Date().toISOString().split('T')[0];

View file

@ -6,7 +6,9 @@
import type { Note } from '$lib/modules/notes/types';
import { NOTE_COLORS } from '$lib/modules/notes/types';
import { notesStore } from '$lib/modules/notes/stores/notes.svelte';
import { noteTagOps, useAllTags } from '$lib/modules/notes/stores/tags.svelte';
import { formatRelativeTime } from '$lib/modules/notes/queries';
import { TagSelector, type Tag } from '@mana/shared-ui';
const allNotes$: Observable<Note[]> = getContext('notes');
let notes = $state<Note[]>([]);
@ -18,6 +20,17 @@
let noteId = $derived($page.params.id);
let note = $derived(notes.find((n) => n.id === noteId));
const allTags = $derived(useAllTags());
let noteTags = $state<Tag[]>([]);
// Sync tags when note changes
$effect(() => {
if (note && noteId) {
noteTagOps.getTagIds(noteId).then((ids: string[]) => {
noteTags = allTags.value.filter((t) => ids.includes(t.id));
});
}
});
let title = $state('');
let content = $state('');
@ -123,6 +136,23 @@
oninput={autoSave}
></textarea>
<!-- Tags -->
<div class="tag-row">
<TagSelector
tags={allTags.value}
selectedTags={noteTags}
onTagsChange={async (tags) => {
noteTags = tags;
await noteTagOps.setTags(
note.id,
tags.map((t) => t.id)
);
}}
placeholder="Tags…"
addTagLabel="Tag hinzufügen"
/>
</div>
<!-- Color + Actions -->
<div class="detail-footer">
<div class="color-row">
@ -247,6 +277,9 @@
color: hsl(var(--color-muted-foreground));
}
.tag-row {
padding: 0.5rem 0;
}
.detail-footer {
display: flex;
justify-content: space-between;