From 7c51bd3788c89fd9f481f98e2962e16011468eea Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Fri, 16 Oct 2026 19:23:36 +0530 Subject: [PATCH 1/2] feat(memory): folder notes follow memory scopes, /scope in chat apps, clearer scope badges Folders get a scope (always by default, also for existing ones): note pieces, folder_search and folder_read only read folders of the run's scope unless asked everywhere, and other folders' pieces are dropped before ranking. Telegram, Discord and WhatsApp get /scope work|personal|, answered without a model. The Memory screen shows the scope as "Used in Work" next to the source, and Settings > Folders has a Used in picker. SCHEMA_VERSION 12. --- CHANGELOG.md | 6 + desktop/src/features/chat/ChatScopeMenu.tsx | 52 +++-- desktop/src/features/memory/MemoryList.tsx | 2 +- desktop/src/features/memory/bits.tsx | 20 +- .../features/settings/sections/Folders.tsx | 17 +- desktop/src/hooks/folders.ts | 11 + desktop/src/lib/api.ts | 1 + desktop/src/lib/types.ts | 3 + docs/API.md | 56 ++++-- sentient/agent/loop.py | 10 +- sentient/channels/base.py | 55 ++++- sentient/channels/discord.py | 3 +- sentient/channels/telegram.py | 3 +- sentient/channels/whatsapp.py | 2 +- sentient/folders/schema.sql | 3 +- sentient/folders/service.py | 121 ++++++++--- sentient/folders/tools.py | 23 ++- sentient/gateway/routes/folders.py | 6 +- sentient/memory/scopes.py | 5 +- sentient/store/db.py | 4 +- tests/channels/test_scope_command.py | 74 +++++++ tests/folders/test_folder_scopes.py | 190 ++++++++++++++++++ 22 files changed, 587 insertions(+), 80 deletions(-) create mode 100644 tests/channels/test_scope_command.py create mode 100644 tests/folders/test_folder_scopes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd71fb19..f104901da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ and versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added +- **Folders and chat apps follow your memory places too.** In Settings, Folders, each folder or Obsidian vault now has + a **Used in** choice: every chat (as before, and what your folders already have), Personal, Work or a project. A + folder kept for Work only comes up in work chats, so a personal chat never brings up your work notes unless you ask. + In Telegram, Discord and WhatsApp, send `/scope work`, `/scope personal` or `/scope` followed by a project's name to + pick which memories that chat uses, or `/scope` to see which it uses now. On the Memory page, where a memory is + kept now reads "Used in Work" next to its source, so it no longer looks like a topic such as "Work & Learning". - **Keep work, personal and project memories apart.** Each memory is now kept for Personal, Work, a project you name, or every chat (for things like your name or how you like answers). Pick a chat's memories from the button at the top of the chat, or before you start one. A work chat then uses your work memories and the ones for every chat, diff --git a/desktop/src/features/chat/ChatScopeMenu.tsx b/desktop/src/features/chat/ChatScopeMenu.tsx index a0247173e..5107ca834 100644 --- a/desktop/src/features/chat/ChatScopeMenu.tsx +++ b/desktop/src/features/chat/ChatScopeMenu.tsx @@ -1,4 +1,4 @@ -import { IconBriefcase, IconCheck, IconFolder, IconHome } from '@tabler/icons-react' +import { IconBriefcase, IconCheck, IconFolder, IconHome, IconInfinity } from '@tabler/icons-react' import { useState, type ReactNode } from 'react' import { Input, Popover, PopoverContent, PopoverTrigger, Tooltip } from '@/components/ui' import { ScopeIcon, scopeLabel } from '@/features/memory/bits' @@ -8,45 +8,73 @@ import { cn } from '@/lib/utils' /** * Which memories this chat uses (#155): Personal, Work or a project. Recall reads that scope plus the memories kept * for every chat, and new memories from the chat are saved there. + * + * `folder` (#392): the same picker for an added folder in Settings > Folders, with Every chat first (its default) and + * no counts, since a folder's notes aren't memories. */ -export function ChatScopeMenu({ value, onChange, className }: { value: string | null | undefined; onChange: (v: string) => void; className?: string }) { +export function ChatScopeMenu({ + value, + onChange, + className, + folder +}: { + value: string | null | undefined + onChange: (v: string) => void + className?: string + /** The folder's name: this picks where its notes are used. */ + folder?: string +}) { const [open, setOpen] = useState(false) const [custom, setCustom] = useState('') const scopes = useMemoryScopes() - const current = value || 'personal' + const fallback = folder ? 'always' : 'personal' + const current = value || fallback const projects = (scopes.data ?? []).filter((s) => !s.built_in).map((s) => s.name) - if (!['personal', 'work', ...projects].includes(current)) projects.unshift(current) - const count = (name: string) => scopes.data?.find((s) => s.name === name)?.count + if (!['always', 'personal', 'work', ...projects].includes(current)) projects.unshift(current) + const count = (name: string) => (folder ? undefined : scopes.data?.find((s) => s.name === name)?.count) const pick = (v: string) => { if (v !== current) onChange(v) setOpen(false) setCustom('') } + const tooltip = folder + ? current === 'always' + ? `Notes in ${folder} are used in every chat` + : `Notes in ${folder} are used in ${scopeLabel(current)} chats` + : `This chat uses your ${scopeLabel(current)} memories` return ( - + - +
- Which memories this chat uses. New ones it learns are kept there too. Memories set to every chat are always used. + {folder + ? 'Which chats use the notes in this folder. A Work folder only comes up in work chats, unless you ask about it.' + : 'Which memories this chat uses. New ones it learns are kept there too. Memories set to every chat are always used.'}
+ {folder && ( + + )} diff --git a/desktop/src/features/memory/MemoryList.tsx b/desktop/src/features/memory/MemoryList.tsx index 78eaa9c47..e0e4a22b4 100644 --- a/desktop/src/features/memory/MemoryList.tsx +++ b/desktop/src/features/memory/MemoryList.tsx @@ -29,7 +29,6 @@ function MemoryCard({ memory, selected, onSelect, now, compact }: { memory: Memo ))} - {typeof memory.similarity === 'number' && ( {Math.round(memory.similarity * 100)}% match )} @@ -43,6 +42,7 @@ function MemoryCard({ memory, selected, onSelect, now, compact }: { memory: Memo {memory.updated_at && memory.created_at && memory.updated_at > memory.created_at && ( · edited {relativeTime(memory.updated_at)} )} + ) diff --git a/desktop/src/features/memory/bits.tsx b/desktop/src/features/memory/bits.tsx index bc2efbcd6..dc4e27697 100644 --- a/desktop/src/features/memory/bits.tsx +++ b/desktop/src/features/memory/bits.tsx @@ -64,16 +64,26 @@ export function ScopeIcon({ scope, size = 15, className }: { scope: string | nul return } -/** Where a memory is kept, shown unless it is the default (Personal). */ +/** + * Where a memory is kept, shown unless it is the default (Personal). It reads "Used in Work" in a dashed, square + * outline next to the source, so it never looks like a topic pill such as "Work & Learning" (#392). + */ export function ScopeBadge({ scope, className }: { scope: string | null | undefined; className?: string }) { if (!scope || scope === 'personal') return null + const where = scope === 'always' ? 'every chat' : scopeLabel(scope) return ( - - {scopeLabel(scope)} + + + Used in {where} + ) } diff --git a/desktop/src/features/settings/sections/Folders.tsx b/desktop/src/features/settings/sections/Folders.tsx index 7be252ebe..72bc635e4 100644 --- a/desktop/src/features/settings/sections/Folders.tsx +++ b/desktop/src/features/settings/sections/Folders.tsx @@ -14,7 +14,9 @@ import { import { useState } from 'react' import { toast } from 'sonner' import { Alert, Badge, Button, ConfirmDialog, EmptyState, FormSection, Input, ProgressBar, Skeleton } from '@/components/ui' -import { useAddFolder, useFolderIgnore, useFolders, useRemoveFolder, useRescanFolder } from '@/hooks/folders' +import { ChatScopeMenu } from '@/features/chat/ChatScopeMenu' +import { scopeLabel } from '@/features/memory/bits' +import { useAddFolder, useFolderIgnore, useFolders, useFolderScope, useRemoveFolder, useRescanFolder } from '@/hooks/folders' import { errorMessage } from '@/lib/api' import { getBridge } from '@/lib/bridge' import type { WatchedFolder } from '@/lib/types' @@ -114,6 +116,7 @@ function FolderCard({ folder: f }: { folder: WatchedFolder }) { const remove = useRemoveFolder() const rescan = useRescanFolder() const setIgnore = useFolderIgnore() + const setScope = useFolderScope() const [confirm, setConfirm] = useState(false) const [ignore, setIgnoreText] = useState(f.ignore.join(', ')) const progress = f.progress && f.progress.total > 0 ? f.progress.done / f.progress.total : null @@ -136,6 +139,17 @@ function FolderCard({ folder: f }: { folder: WatchedFolder }) { } ) } + const changeScope = (scope: string) => + setScope.mutate( + { id: f.id, scope }, + { + onSuccess: () => + toast.success(scope === 'always' ? `${f.name} is used in every chat` : `${f.name} is used in ${scopeLabel(scope)} chats`, { + description: scope === 'always' ? undefined : 'Other chats leave its notes out, unless you ask about them.' + }), + onError: (err) => toast.error("That didn't save", { description: errorMessage(err) }) + } + ) return (
@@ -161,6 +175,7 @@ function FolderCard({ folder: f }: { folder: WatchedFolder }) { {f.status === 'scanning' && } {f.error &&

{f.error}

}
+ {f.scope !== undefined && } (fn: (arg: A) => Promise) { export const useAddFolder = () => useFolderMutation((path: string) => api.folders.add(path)) export const useRemoveFolder = () => useFolderMutation((id: string) => api.folders.remove(id)) export const useRescanFolder = () => useFolderMutation((id: string) => api.folders.rescan(id)) +/** Where a folder's notes are used (#392). A new project name also shows up in the memory scope pickers. */ +export function useFolderScope() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, scope }: { id: string; scope: string }) => api.folders.setScope(id, scope), + onSettled: () => { + void qc.invalidateQueries({ queryKey: qk.folders }) + void qc.invalidateQueries({ queryKey: qk.memories.scopes }) + } + }) +} export const useFolderIgnore = () => useFolderMutation(({ id, ignore }: { id: string; ignore: string[] }) => api.folders.update(id, ignore)) diff --git a/desktop/src/lib/api.ts b/desktop/src/lib/api.ts index cf0eed568..895fda5ef 100644 --- a/desktop/src/lib/api.ts +++ b/desktop/src/lib/api.ts @@ -873,6 +873,7 @@ export const api = { list: () => http.get('/api/folders'), add: (path: string, ignore?: string[]) => http.post('/api/folders', { path, ignore }), update: (id: string, ignore: string[]) => http.patch(`/api/folders/${id}`, { ignore }), + setScope: (id: string, scope: string) => http.patch(`/api/folders/${id}`, { scope }), remove: (id: string) => http.delete<{ removed: boolean }>(`/api/folders/${id}`), rescan: (id: string) => http.post<{ queued: boolean }>(`/api/folders/${id}/rescan`, {}) }, diff --git a/desktop/src/lib/types.ts b/desktop/src/lib/types.ts index f4ab2fa8f..215ba1760 100644 --- a/desktop/src/lib/types.ts +++ b/desktop/src/lib/types.ts @@ -2929,6 +2929,9 @@ export interface WatchedFolder { kind: 'folder' | 'obsidian' /** This folder's own ignore patterns (Settings > Folders adds them to `folders.ignore`). */ ignore: string[] + /** The chats its notes are used in (#392): `always` (every chat, the default), `personal`, `work` or a project. + * Missing on older engines. */ + scope?: string status: FolderStatus /** While a look through it runs: new or changed files read so far. */ progress: { done: number; total: number } | null diff --git a/docs/API.md b/docs/API.md index 619d5d12d..ae064c870 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1572,7 +1572,7 @@ attribute and no word of what it says in common ("a piano recital in July" for " - `DELETE /api/memories/{id}` → `{deleted: true}` (404 if missing) - `DELETE /api/memories/source/{source}` → `{deleted: n}` - `GET /api/memories/scopes` → `[{name, label, count, built_in}]`: `always`, `personal` and `work` first, then every - project a memory, chat, task or messaging app uses, by name; `count` = active memories in it. + project a memory, chat, task, messaging app or added folder uses, by name; `count` = active memories in it. - `DELETE /api/memories/scope/{scope}` → `{deleted: n}`: resets one scope. Every memory in it goes (also ones waiting for review), nothing in any other scope changes. - `POST /api/memories/export` `{format: "markdown"|"sqlite"}` → `{path, folder, name, format, memories, about_you, @@ -1623,8 +1623,13 @@ project's own name (`"Kestrel launch"`, up to 40 characters; `Work`/`PERSONAL` a insights). - **Incognito** chats (section 2) have a scope too; it only decides what they read, as nothing is saved from them. - **Dreaming** (section 15) never merges or settles two memories from different scopes. -- **Backups** (section 20) carry scopes in `sentient.db` (`summaries.scope` too); restoring a backup from before scopes puts every memory in - `personal` (`SCHEMA_VERSION` 11). +- **Folders** (#392, section 22) have a scope too (`folders.scope`, default `always`, also for folders added before + it): their notes come up only in runs that read that scope (note pieces in a chat's prompt, `folder_search`, + `folder_read`), and `GET /api/memories/scopes` lists a project a folder uses. +- **Messaging apps:** `/scope ` sets a chat's `sessions.memory_scope` from the app itself + (section 14). +- **Backups** (section 20) carry scopes in `sentient.db` (`summaries.scope` and `folders.scope` too); restoring a backup from before scopes puts every memory in + `personal` (`SCHEMA_VERSION` 11) and every folder in `always` (`SCHEMA_VERSION` 12). ### Memories waiting for review (ADR 0021) A memory that did not come from the user's own words in a clean chat is held: `status: "pending"` for facts, status @@ -2367,7 +2372,7 @@ A device ("node") is a phone, a pair of smart glasses, a watch, or the desktop a (groups are ignored). Discord: direct messages only (server messages are ignored). - A paired chat is a normal Sentient chat (`channel` = channel id, so the desktop shows a badge). Commands: `/new` starts a fresh chat (new session), `/stop` cancels the reply (the partial text is kept with "(stopped)"), `/stopall` (or - `/stop all`) is Stop everything and `/resume` undoes it (section 17; source `telegram` or `discord`), `/model`, `/help`; other + `/stop all`) is Stop everything and `/resume` undoes it (section 17; source `telegram` or `discord`), `/model`, `/scope`, `/help`; other `/commands` get a hint. Unpaired chats cannot use any command except `/pair`. Replies stream by editing the message at most once per `channels..edit_interval_s` (1 s) when `stream_edits` is on; long replies are split (Telegram 4096, Discord 2000 characters, code blocks kept balanced). Telegram replies use HTML parse mode (bold, italics, strikethrough, code, code blocks, links, lists, quotes; everything @@ -2378,6 +2383,12 @@ A device ("node") is a phone, a pair of smart glasses, a watch, or the desktop a options). Choosing one applies it, settles the message to "Switched to " and sends anything still missing ("Still needed: ..."). `/model ` applies directly (numbers count the usable presets in order), `/model undo` undoes the last switch. Paired chats only, like every command. +- `/scope` (#392) picks the memories this chat uses and saves to (its `sessions.memory_scope`, section 7 "Memory + scopes"; publishes `session.updated` with `memory_scope`), answered without a model: `/scope work`, `/scope personal` + or `/scope ` (an existing project matches whatever the case and spacing; a new name starts a project), + `/scope default` follows `channels..memory_scope` again, `/scope` alone says which one it uses and lists the + projects. `/scope always` is refused with a plain reason. `/new` keeps the chat's scope. Paired chats only, like + every command. - `/model chat ` (#312) keeps a model for this conversation only (its `sessions.model`, section 2; publishes `session.updated`); presets and other chats are untouched. It is refused with the plain reason when the model can't answer right now (no key, Claude Code off, not downloaded). `/model chat default` follows the default @@ -3042,9 +3053,10 @@ Sentient takes one: A snapshot that fails is logged and never keeps Sentient from starting. Memory scopes (#155) are part of `sentient.db` (`facts.scope`, `sessions.memory_scope`, `tasks.memory_scope`, -`summaries.scope`), so a +`summaries.scope`, `folders.scope`), so a backup keeps them. Restoring a backup made before them (database version 10 or older) adds the columns on the next -start, with every memory in `personal`. +start, with every memory in `personal`; one made before folder scopes (version 11 or older) puts every folder in +`always` (#392). **Restore** is two steps, so nothing in use is swapped under the running engine: `POST /api/backups/restore` checks the archive (manifest, names, SQLite `quick_check`) and unpacks it into `/backups/restore-pending`; the next engine @@ -3231,6 +3243,15 @@ answer scored 0.58 to 0.74 and unrelated ones at most 0.65. Clipped web pages ne `folder_search` is offered (a folder is added); an incognito chat without memory gets none. The look-up gives up after 4 s. The notes the reply drew on show as sources with `via: "prompt"` (section 2, "Answer sources"). +**Scopes (#392).** A folder has a memory scope (section 7, "Memory scopes"): `always` (the default, and what folders +added before scopes have), `personal`, `work` or a project's name. A run reads a folder when the folder's scope is +`always` or the run's scope: note pieces in a chat's prompt use the chat's scope, `folder_search` and `folder_read` the +run's (`ToolContext`, as `memory_recall`). Work not tied to a chat or task, and `POST /api/folders/search`, read every +folder. `folder_search(everywhere=true)` reads every folder, and a result from a folder of another scope then carries +`kept_for` (its scope's label). When every folder belongs to another scope, `folder_search` returns +`{results: [], note}` saying so; `folder_read` of a note there returns an `error` naming the scope, and reads it with +`everywhere=true`. + **Clipped web pages (ADR 0025).** A note with a web address under `source`, `url`, `clipped_from`, `source_url`, `original_url` or `link` in its front matter, a `clippings`-style tag, or in a `Clippings` folder is a clipping. With `folders.clippings_are_outside` (default on) a `folder_search` result that includes one, and a `folder_read` of one, @@ -3238,36 +3259,39 @@ start with `outside_content: "a web page clipped into your notes"`, so the chat (section 10). The user's own notes are not outside content. ### Tools (plugin `folders`, "Your folders"; hidden while no folder is added, and `folder_search` is offered on every chat turn while one is, also to local models) -- `folder_search(query, folder = "", limit = 0)` (risk `read`) → `{outside_content?, results: [{title, folder, path, - heading?, text, tags?, file, link?, clipped_from?, similarity?}]}` or `{results: [], note}`. `path` is inside the - folder, `file` the full path, `link` an `obsidian://open?vault=&file=` link in a vault. `folder` is a folder's name - or id. -- `folder_read(path, offset = 0, limit = 0)` (risk `read`) → `{outside_content?, title, folder, path, file, link?, +- `folder_search(query, folder = "", limit = 0, everywhere = false)` (risk `read`) → `{outside_content?, results: + [{title, folder, path, heading?, text, tags?, file, link?, clipped_from?, similarity?, kept_for?}]}` or + `{results: [], note}`. `path` is inside the folder, `file` the full path, `link` an `obsidian://open?vault=&file=` + link in a vault. `folder` is a folder's name or id. Only folders the run's scope reads unless `everywhere`. +- `folder_read(path, offset = 0, limit = 0, everywhere = false)` (risk `read`) → `{outside_content?, title, folder, path, file, link?, clipped_from?, offset, content, total, more, next_offset?}`: a whole indexed file read now from disk, in parts like `file_read`. `path` is the full path, the path inside the folder, or `/` (`.md` may be left off). Only files in the index that the current ignore rules keep can be read (never hidden or ignored ones), and whether it is a clipping is checked on the file as it is now. ### REST -A **Folder** is `{id, path, name, kind: "folder"|"obsidian", ignore: string[], status: "waiting"|"scanning"|"ready"| +A **Folder** is `{id, path, name, kind: "folder"|"obsidian", ignore: string[], scope, status: "waiting"|"scanning"|"ready"| "missing", progress: {done, total}|null, added_at, last_scan_at, last_scan_ms, files, unreadable, chunks, embedded, skipped, clippings, error}`: `files` read, `unreadable` that could not be read, `chunks` searchable pieces and `embedded` how many of them are searchable by meaning, `skipped` left out at the last scan (too big, over `max_files`), `clippings` clipped web pages, `error` a plain sentence (the folder can't be found, only part of it is -searched). `progress` counts the new or changed files of the running scan. +searched). `progress` counts the new or changed files of the running scan. `scope`: the chats its notes are used in +(above). - `GET /api/folders` → `{folders: [Folder], searching_by_meaning, embedding_error}` -- `POST /api/folders` `{path, ignore?}` → Folder, and a scan starts. 400 with a plain `detail` when the path is not a +- `POST /api/folders` `{path, ignore?, scope?}` → Folder (`scope` defaults to `always`), and a scan starts. 400 with a plain `detail` when the path is not a full path to an existing folder, is a whole drive or the home folder, holds or is inside Sentient's data folder, is already added, or is inside or holds an added folder. -- `PATCH /api/folders/{id}` `{ignore}` → Folder, and it is scanned again. 404 when unknown. +- `PATCH /api/folders/{id}` `{ignore?, scope?}` → Folder. New `ignore` patterns scan it again; a new `scope` (empty + is `always`) takes effect at once and sends `folders.updated`. 404 when unknown. - `DELETE /api/folders/{id}` → `{removed: true}`: forgets the folder and its whole index. 404 when unknown. - `POST /api/folders/{id}/rescan` → 202 `{queued: true}`. 404 when unknown. - `POST /api/folders/search` `{query, folder?, limit?}` → what `folder_search` returns. - Event `folders.updated` (section 1). Settings > Folders lists them with their status and counts, adds one with the folder picker, removes one (with a -confirmation), checks again, edits the folder's ignore patterns and shows the `folders` config section. +confirmation), checks again, edits the folder's ignore patterns, picks where it is used (Every chat, Personal, Work +or a project) and shows the `folders` config section. ## 23. Timers, reminders, alarms and quick commands (owner: reminders and core, #150, #359) diff --git a/sentient/agent/loop.py b/sentient/agent/loop.py index a17f8832e..136f6bcc6 100644 --- a/sentient/agent/loop.py +++ b/sentient/agent/loop.py @@ -616,16 +616,17 @@ async def _recall(self, user_text: str, scope: str | None = None) -> list[dict]: log.warning("recall failed: %s", exc) return [] - async def _prompt_notes(self, user_text: str) -> list[dict]: + async def _prompt_notes(self, user_text: str, scope: str | None = None) -> list[dict]: """Short pieces of the user's notes that clearly match the message (#354), while a folder is added and - folder_search is offered. Never slows a turn down much or breaks it.""" + folder_search is offered, from the folders the chat's scope reads (#392). Never slows a turn down much or + breaks it.""" folders = getattr(self.app, "folders", None) if self.app is not None else None if folders is None or self.config.folders.prompt_notes <= 0 or not user_text.strip(): return [] if not any(t.name == "folder_search" for t in self.registry.tools()): return [] try: - pieces = await asyncio.wait_for(folders.prompt_pieces(user_text), timeout=NOTES_TIMEOUT_S) + pieces = await asyncio.wait_for(folders.prompt_pieces(user_text, scope), timeout=NOTES_TIMEOUT_S) log.info("note pieces in the prompt: %s", ", ".join(str(p.get("path")) for p in pieces) or "none matched") return pieces except asyncio.CancelledError: @@ -650,7 +651,8 @@ async def _system_prompt_with_sources( personal = memory_scopes.is_personal(scope) if reads_memory: facts, (user_context, insights), notes = await asyncio.gather( - self._recall(user_text, scope), self._user_model_context(user_text, scope), self._prompt_notes(user_text) + self._recall(user_text, scope), self._user_model_context(user_text, scope), + self._prompt_notes(user_text, scope), ) else: facts, user_context, insights, notes = [], "", [], [] diff --git a/sentient/channels/base.py b/sentient/channels/base.py index 954e11c28..a11b480c0 100644 --- a/sentient/channels/base.py +++ b/sentient/channels/base.py @@ -40,6 +40,7 @@ ToolResultEvent, UndoWindow, ) +from sentient.memory import scopes as memory_scopes if TYPE_CHECKING: # pragma: no cover from sentient.channels.service import ChannelService @@ -59,6 +60,7 @@ "charts": "Making a chart", "files": "Working with files", "memory": "Checking my memory", + "folders": "Looking in your notes", "skills": "Looking through my skills", "time": "Checking the time", "tasks": "Working on your tasks", @@ -468,7 +470,9 @@ async def _handle_incoming(self, rt: ChatRuntime, msg: Incoming) -> None: if cmd == "new": await self.cancel_turn(chat_id) rt.queued.clear() - session_id = await self.app.store.create_session(channel=self.id) + # a fresh chat keeps the memories this chat was set to with /scope (#392) + old = await self.app.store.get_session(chat["session_id"]) if chat.get("session_id") else None + session_id = await self.app.store.create_session(channel=self.id, memory_scope=(old or {}).get("memory_scope")) await store.update_chat(self.id, chat_id, session_id=session_id) await self.service.publish_channel(self.id) await self.reply(chat_id, "Started a fresh chat. What's next?") @@ -492,6 +496,9 @@ async def _handle_incoming(self, rt: ChatRuntime, msg: Incoming) -> None: else: await self._model_command(chat_id, arg) return + if cmd == "scope": + await self._scope_command(chat, arg) + return if cmd == "stop": rt.queued.clear() if await self.cancel_turn(chat_id): @@ -629,6 +636,7 @@ def help_text(self, chat: dict | None) -> str: "/resume - start scheduled tasks and suggestions again after /stopall", "/model - switch between local and cloud models", "/model chat - use a model in this chat only (/model chat default to go back)", + "/scope - which memories this chat uses: /scope work, /scope personal or /scope ", "/help - show this message", ] if chat and chat.get("deliver"): @@ -1000,6 +1008,51 @@ async def _chat_model_command(self, chat: dict, value: str) -> None: else: await self.reply(chat_id, f"This chat now uses {availability.label(model)}. Send /model chat default to go back.") + # ------------------------------------------------------------------ /scope + async def _scope_command(self, chat: dict, value: str) -> None: + """``/scope work``, ``/scope personal`` or ``/scope `` picks the memories (and folder notes) this chat + uses and saves to (#392), like the scope picker in a chat's header; ``/scope default`` follows the app's + setting again; ``/scope`` says which it uses. Answered here, never by a model.""" + chat_id = str(chat["chat_id"]) + session_id = await self.ensure_session(chat) + session = await self.app.store.get_session(session_id) or {} + current = memory_scopes.of_session(self.app.config, session) + names = [s["name"] for s in await memory_scopes.list_scopes(self.app)] + projects = [n for n in names if n not in memory_scopes.BUILT_IN] + value = " ".join(value.split()) + if not value: + lines = [f"This chat uses your {memory_scopes.label(current)} memories, plus the ones kept for every chat.", + "Send /scope work, /scope personal or /scope followed by a project's name to change it."] + if projects: + lines.append("Your projects: " + ", ".join(projects) + ".") + await self.reply(chat_id, "\n".join(lines)) + return + if value.lower() in {"default", "reset"}: + scope = None + else: + try: + scope = memory_scopes.chat_scope(value) + except ValueError: + await self.reply(chat_id, "A chat can't use only the memories kept for every chat. " + "Send /scope personal, /scope work or /scope followed by a project's name.") + return + # an existing project keeps its spelling: "/scope kestrel launch" is "Kestrel launch" + scope = next((n for n in projects if scope and n.lower() == scope.lower()), scope) + await self.app.store.set_session_scope(session_id, scope) + now = memory_scopes.of_session(self.app.config, {**session, "memory_scope": scope}) + self.app.bus.publish("session.updated", {"session_id": session_id, "memory_scope": now}) + log.info("%s: a chat now uses %s memories (/scope)", self.id, now) + if now == current: + await self.reply(chat_id, f"This chat already uses your {memory_scopes.label(now)} memories.") + return + new = scope is not None and scope not in memory_scopes.BUILT_IN and scope not in projects + lines = [f"Done. This chat now uses your {memory_scopes.label(now)} memories, plus the ones kept for every chat.", + f"It no longer brings up your {memory_scopes.label(current)} memories, and what it learns is kept for " + f"{memory_scopes.label(now)}."] + if new: + lines.append(f"{now} is a new project, so it has no memories yet.") + await self.reply(chat_id, "\n".join(lines)) + async def _preset_outcome(self, name: str | None) -> str: """Apply a preset (None = undo the last switch) and describe the result in plain words.""" try: diff --git a/sentient/channels/discord.py b/sentient/channels/discord.py index 42832f183..ab91279d5 100644 --- a/sentient/channels/discord.py +++ b/sentient/channels/discord.py @@ -64,7 +64,8 @@ Now send the bot a direct message to talk to Sentient: text, voice messages, images and files all work. Commands: `/new` starts a fresh chat, `/stop` stops a reply, `/stopall` stops everything Sentient is doing -(`/resume` starts it again), `/model` switches between local and cloud models, `/help` shows help. +(`/resume` starts it again), `/model` switches between local and cloud models, `/scope work` or `/scope personal` +keeps this chat to those memories, `/help` shows help. **Keep the token private.** If it leaks, click **Reset Token** again and connect with the new one. """ diff --git a/sentient/channels/telegram.py b/sentient/channels/telegram.py index 0a94ada95..b8010b07e 100644 --- a/sentient/channels/telegram.py +++ b/sentient/channels/telegram.py @@ -52,7 +52,8 @@ def api_base() -> str: Now just message your bot to talk to Sentient. You can send text, voice notes, photos and files. Commands: `/new` starts a fresh chat, `/stop` stops a reply, `/stopall` stops everything Sentient is doing -(`/resume` starts it again), `/model` switches between local and cloud models, `/help` shows help. +(`/resume` starts it again), `/model` switches between local and cloud models, `/scope work` or `/scope personal` +keeps this chat to those memories, `/help` shows help. **Keep the token private.** Anyone who has it can use your bot. If it leaks, send `/revoke` to BotFather, then connect again here with the new token. diff --git a/sentient/channels/whatsapp.py b/sentient/channels/whatsapp.py index 168cc5975..33bf992bc 100644 --- a/sentient/channels/whatsapp.py +++ b/sentient/channels/whatsapp.py @@ -56,7 +56,7 @@ You can send text, voice notes, photos and files. When Sentient needs a yes or no, it lists numbered options: reply with the number. Commands: `/new` starts a fresh chat, `/stop` stops a reply, `/stopall` stops everything Sentient is doing (`/resume` starts it again), `/model` switches between local and cloud -models, `/help` shows help. +models, `/scope work` or `/scope personal` keeps this chat to those memories, `/help` shows help. **Good to know:** this uses the same linking as WhatsApp Web, through an unofficial app. WhatsApp does not officially support assistants on personal accounts and could limit or ban an account that uses one. That is diff --git a/sentient/folders/schema.sql b/sentient/folders/schema.sql index 6a1b7e3c9..cabff172e 100644 --- a/sentient/folders/schema.sql +++ b/sentient/folders/schema.sql @@ -11,7 +11,8 @@ CREATE TABLE IF NOT EXISTS folders ( last_scan_at TEXT, last_scan_ms INTEGER, skipped INTEGER NOT NULL DEFAULT 0, -- files left out at the last look (too big, unreadable, over the cap) - error TEXT -- plain sentence, or NULL + error TEXT, -- plain sentence, or NULL + scope TEXT NOT NULL DEFAULT 'always' -- the chats its notes are recalled in: always, personal, work, a project (#392) ); CREATE TABLE IF NOT EXISTS folder_files ( diff --git a/sentient/folders/service.py b/sentient/folders/service.py index cd036998e..72ff0148a 100644 --- a/sentient/folders/service.py +++ b/sentient/folders/service.py @@ -20,6 +20,11 @@ (ADR 0018). A note that is a web page clipped into the vault (a source link in its front matter, a clippings tag or a Clippings folder) was written by someone else: with ``folders.clippings_are_outside`` (default on) a result that includes one carries ``outside_content`` first, and the agent loop treats it like a web page (ADR 0025). + +**Scopes (#392).** A folder has a memory scope like a memory (``sentient.memory.scopes``): ``always`` (the default, and +what folders added before scopes got), ``personal``, ``work`` or a project. Its notes come up only in runs of a +matching scope (that scope plus ``always``): note pieces in a chat's prompt, ``folder_search`` and ``folder_read``. +Work not tied to a chat or task searches every folder, and ``everywhere=true`` does too when the user asks. """ from __future__ import annotations @@ -38,6 +43,7 @@ from sentient import paths from sentient.folders import notes as notes_mod from sentient.folders.scan import MARKDOWN, Ignore, is_obsidian, obsidian_excluded, supported, walk +from sentient.memory import scopes as memory_scopes from sentient.memory.vectors import VecTable, pack from sentient.services import Service from sentient.store.db import new_id, now_iso @@ -196,7 +202,7 @@ def _public(self, row: dict, stats: dict) -> dict: status = "waiting" return { "id": fid, "path": row["path"], "name": row["name"], "kind": row["kind"], - "ignore": json.loads(row.get("ignore") or "[]"), "status": status, + "ignore": json.loads(row.get("ignore") or "[]"), "scope": folder_scope(row.get("scope")), "status": status, "progress": self._progress.get(fid), "added_at": row["added_at"], "last_scan_at": row.get("last_scan_at"), "last_scan_ms": row.get("last_scan_ms"), "skipped": int(row.get("skipped") or 0), "error": row.get("error"), **stats, @@ -240,13 +246,13 @@ def _check_new(self, path: str, existing: list[dict]) -> Path: raise FolderError(f"That folder holds {f['name']}, which is already added. Remove it first.") return p - async def add(self, path: str, ignore: list[str] | None = None) -> dict: + async def add(self, path: str, ignore: list[str] | None = None, scope: str | None = None) -> dict: p = self._check_new(path, await self._rows()) fid = new_id() kind = "obsidian" if is_obsidian(p) else "folder" await self.app.store.execute( - "INSERT INTO folders(id, path, name, kind, ignore, added_at) VALUES(?,?,?,?,?,?)", - (fid, str(p), p.name or str(p), kind, json.dumps(_clean_patterns(ignore)), now_iso()), + "INSERT INTO folders(id, path, name, kind, ignore, added_at, scope) VALUES(?,?,?,?,?,?,?)", + (fid, str(p), p.name or str(p), kind, json.dumps(_clean_patterns(ignore)), now_iso(), folder_scope(scope)), ) await self._apply_visibility() self._watch_changed.set() # watch it too @@ -254,9 +260,15 @@ async def add(self, path: str, ignore: list[str] | None = None) -> dict: log.info("folder added: %s (%s)", p, kind) return await self.get(fid) or {"id": fid} - async def update(self, folder_id: str, ignore: list[str] | None) -> dict | None: - if await self._row(folder_id) is None: + async def update(self, folder_id: str, ignore: list[str] | None, scope: str | None = None) -> dict | None: + """New ignore patterns (the folder is read again) and/or a new scope (#392; nothing to read again).""" + row = await self._row(folder_id) + if row is None: return None + if scope is not None and folder_scope(scope) != folder_scope(row.get("scope")): + await self.app.store.execute("UPDATE folders SET scope = ? WHERE id = ?", (folder_scope(scope), folder_id)) + log.info("folder %s is now used in %s chats", row["path"], folder_scope(scope)) + self._publish(folder_id, force=True) if ignore is not None: await self.app.store.execute( "UPDATE folders SET ignore = ? WHERE id = ?", (json.dumps(_clean_patterns(ignore)), folder_id) @@ -576,26 +588,46 @@ async def _embed_pending(self, fid: str) -> None: await db.commit() # ------------------------------------------------------------------ search - async def search(self, query: str, *, limit: int | None = None, folder: str = "") -> dict: - """The pieces of the user's notes that best match ``query``, best first, at most ``PER_FILE`` per file.""" - return (await self._search(query, limit=limit, folder=folder))[0] + async def search( + self, query: str, *, limit: int | None = None, folder: str = "", scope: str | None = None, + everywhere: bool = False, + ) -> dict: + """The pieces of the user's notes that best match ``query``, best first, at most ``PER_FILE`` per file. Only + folders a run in ``scope`` reads (#392; None: every folder), unless ``everywhere``: a result from a folder kept + for another scope then says which (``kept_for``).""" + return (await self._search(query, limit=limit, folder=folder, scope=scope, everywhere=everywhere))[0] async def _search( - self, query: str, *, limit: int | None = None, folder: str = "", window: bool = True + self, query: str, *, limit: int | None = None, folder: str = "", window: bool = True, + scope: str | None = None, everywhere: bool = False, ) -> tuple[dict, bool]: """``(search result, whether search by meaning ran)``. Without ``window`` every piece close enough in meaning counts, not only those near the best one, and carries its similarity.""" query = str(query or "").strip() limit = max(1, min(int(limit or self.app.config.folders.results), 20)) - rows = await self._rows() + every = await self._rows() + rows = every if everywhere else [r for r in every if in_scope(r, scope)] if folder: want = folder.strip().lower() rows = [r for r in rows if r["id"] == folder or r["name"].lower() == want] or rows + searched = {r["id"] for r in rows} + # folders kept for other chats (#392): named in a note when nothing here matches, never their content + other = ", ".join(sorted({memory_scopes.label(folder_scope(r.get("scope"))) for r in every + if r["id"] not in searched})) if not rows: + if every: + return {"results": [], "note": ( + f"The user's folders are kept for other chats ({other}). Search with everywhere=true only when the " + "user asks about those notes." + )}, False return {"results": [], "note": "No folders are added yet. The user can add them in Settings > Folders."}, False if not query: return {"error": "Say what to look for."}, False allowed = {r["id"]: r for r in rows} + # only some folders (a scope, #392, or one folder by name): drop the others' pieces before ranking, so a + # better match in a folder that isn't searched never pushes this one's out of the results or the window + only = set(allowed) if len(allowed) < len(every) else None + k = CANDIDATES * 4 if only else CANDIDATES scores: dict[int, float] = {} similarity: dict[int, float] = {} keyword_rank: dict[int, int] = {} @@ -605,8 +637,11 @@ async def _search( try: fts = await self.app.store.fetchall( "SELECT rowid, bm25(folder_fts, 4.0, 2.0, 1.0) AS b FROM folder_fts WHERE folder_fts MATCH ?" - " ORDER BY b LIMIT ?", (match, CANDIDATES), + " ORDER BY b LIMIT ?", (match, k), ) + if only: + keep = await self._in_folders([int(r[0]) for r in fts], only) + fts = [r for r in fts if int(r[0]) in keep][:CANDIDATES] except Exception as exc: log.debug("keyword search failed: %s", exc) fts = [] @@ -620,7 +655,10 @@ async def _search( if await self._vec_ready(): try: qv = (await self.app.llm.embed([query]))[0] - hits = await self.vec.knn(qv, CANDIDATES) + hits = await self.vec.knn(qv, k) + if only: + keep = await self._in_folders([cid for cid, _ in hits], only) + hits = [h for h in hits if h[0] in keep][:CANDIDATES] except asyncio.CancelledError: raise except Exception as exc: @@ -632,8 +670,13 @@ async def _search( for rank, (cid, sim) in enumerate(h for h in hits if h[1] >= floor): scores[cid] = scores.get(cid, 0.0) + 1.0 / (RRF_K + rank) similarity[cid] = sim + nothing = {"results": [], "note": ( + f"Nothing in the folders this chat uses matches that. Folders kept for {other} chats weren't searched. If " + "they could hold the answer, say so and offer to look; search them with everywhere=true when the user wants." + if other else "Nothing in the user's folders matches that." + )} if not scores: - return {"results": [], "note": "Nothing in the user's folders matches that."}, by_meaning + return nothing, by_meaning # best fused score first; on a tie the better keyword match wins (names and exact words are precise) ranked = sorted(scores, key=lambda c: (-round(scores[c], 9), keyword_rank.get(c, CANDIDATES))) marks = ",".join("?" * len(ranked)) @@ -653,9 +696,14 @@ async def _search( if per_file.get(key, 0) >= PER_FILE: continue per_file[key] = per_file.get(key, 0) + 1 - out.append(self._result(allowed[c["folder_id"]], c, similarity.get(cid))) + item = self._result(allowed[c["folder_id"]], c, similarity.get(cid)) + if everywhere and scope is not None and not in_scope(allowed[c["folder_id"]], scope): + item["kept_for"] = memory_scopes.label(folder_scope(allowed[c["folder_id"]].get("scope"))) + out.append(item) if len(out) >= limit: break + if not out: + return nothing, by_meaning result: dict = {} clipped = [r for r in out if r.get("clipped_from")] if clipped and self.app.config.folders.clippings_are_outside: @@ -663,21 +711,31 @@ async def _search( result["results"] = out return result, by_meaning - async def prompt_pieces(self, query: str) -> list[dict]: + async def _in_folders(self, chunk_ids: list[int], folder_ids: set[str]) -> set[int]: + """The pieces of ``chunk_ids`` that belong to one of ``folder_ids``.""" + if not chunk_ids or not folder_ids: + return set() + rows = await self.app.store.fetchall( + f"SELECT id FROM folder_chunks WHERE id IN ({','.join('?' * len(chunk_ids))})" + f" AND folder_id IN ({','.join('?' * len(folder_ids))})", [*chunk_ids, *folder_ids], + ) + return {int(r[0]) for r in rows} + + async def prompt_pieces(self, query: str, scope: str | None = None) -> list[dict]: """Up to ``folders.prompt_notes`` short pieces of the user's notes that clearly match ``query``, one per note, best first, to go with a chat message (#354): small models rarely search notes on their own. A piece counts when it is close in meaning (``folders.prompt_min_similarity``) and holds at least half of the message's words, or is very close in meaning (``STRONG_MARGIN`` more); without search by meaning, when it holds most of the message's words. Clipped web pages never go in (they are outside content, ADR 0025: ``folder_search`` still finds them). Each is ``{title, folder, path, file, link?, heading?, text}`` with ``text`` cut to - ``folders.prompt_note_chars`` around the words that matched.""" + ``folders.prompt_note_chars`` around the words that matched. Only folders the chat's ``scope`` reads (#392).""" cfg = self.app.config.folders query = str(query or "").strip() - if cfg.prompt_notes <= 0 or len(query) < 3 or not await self._rows(): + if cfg.prompt_notes <= 0 or len(query) < 3 or not any(in_scope(r, scope) for r in await self._rows()): return [] # every close piece with its similarity: a clipped page that matches best must not hide the user's own note # the most results a search gives, so clipped pages and pieces that don't qualify can't crowd out one that does - found, by_meaning = await self._search(query, limit=20, window=False) + found, by_meaning = await self._search(query, limit=20, window=False, scope=scope) words = content_words(query) out: list[dict] = [] files: set[str] = set() @@ -724,13 +782,14 @@ def _result(self, folder: dict, c: dict, sim: float | None) -> dict: return {k: v for k, v in item.items() if v is not None} # ------------------------------------------------------------------ reading one file - async def resolve(self, path: str) -> tuple[dict, dict] | None: + async def resolve(self, path: str, scope: str | None = None) -> tuple[dict, dict] | None: """``(folder, file row)`` of an indexed file named by its full path, its path inside a folder, or - "/". Only files Sentient indexed can be read (never hidden or ignored ones).""" + "/". Only files Sentient indexed can be read (never hidden or ignored ones), in a + folder a run in ``scope`` reads (#392; None: any folder).""" raw = str(path or "").strip().strip('"').replace("\\", "/") if not raw: return None - rows = await self._rows() + rows = [r for r in await self._rows() if in_scope(r, scope)] candidates: list[tuple[dict, str]] = [] for f in rows: if os.path.isabs(raw.replace("/", os.sep)) or raw.startswith("/"): @@ -750,9 +809,10 @@ async def resolve(self, path: str) -> tuple[dict, dict] | None: return f, dict(row) # a file the current rules leave out can't be read, even before a rescan return None - async def read(self, path: str) -> tuple[dict, dict, str] | None: - """``(folder, file row, text)`` of an indexed file, read now from disk. None when it isn't one.""" - found = await self.resolve(path) + async def read(self, path: str, scope: str | None = None) -> tuple[dict, dict, str] | None: + """``(folder, file row, text)`` of an indexed file in a folder a run in ``scope`` reads, read now from disk. + None when it isn't one.""" + found = await self.resolve(path, scope) if found is None: return None folder, row = found @@ -851,6 +911,17 @@ def real_file(root: Path, rel: str) -> Path: return path +def folder_scope(raw: Any) -> str: + """A folder's scope as stored: ``always`` when empty, else a scope name (``sentient.memory.scopes.normalize``).""" + return memory_scopes.normalize(raw, memory_scopes.ALWAYS) or memory_scopes.ALWAYS + + +def in_scope(row: dict, scope: str | None) -> bool: + """Whether a run in ``scope`` (None: not tied to a chat or task) reads this folder's notes.""" + wanted = memory_scopes.recall_scopes(scope) + return wanted is None or folder_scope(row.get("scope")) in wanted + + def _clean_patterns(patterns: list[str] | None) -> list[str]: out = [] for p in patterns or []: diff --git a/sentient/folders/tools.py b/sentient/folders/tools.py index e1eb41182..596db74ef 100644 --- a/sentient/folders/tools.py +++ b/sentient/folders/tools.py @@ -6,7 +6,8 @@ from typing import Any from sentient.folders.notes import obsidian_url -from sentient.folders.service import CLIPPED_SOURCE +from sentient.folders.service import CLIPPED_SOURCE, folder_scope +from sentient.memory import scopes as memory_scopes from sentient.tools.base import Risk, ToolContext, ToolPlugin, tool from sentient.tools.builtin.files_tool import _fit_part, part_budget @@ -17,31 +18,41 @@ def _service(ctx: ToolContext) -> Any: # The user's own notes are not outside content (ADR 0018); a clipped web page among them is, per result (ADR 0025). @tool("folder_search", risk=Risk.read, untrusted_output=False) -async def folder_search(ctx: ToolContext, query: str, folder: str = "", limit: int = 0) -> dict: +async def folder_search(ctx: ToolContext, query: str, folder: str = "", limit: int = 0, everywhere: bool = False) -> dict: """Search the notes and documents in the folders the user added (an Obsidian vault, a documents folder). Use it when the user asks about their notes, projects, people, plans or anything they may have written down. `query` is what to look for in plain words. `folder` limits it to one folder by name (empty = all). + It searches the folders this chat uses (for example Work). Set everywhere=true only when the user asks about + notes from another part of their life. Each result has the note's `title`, `path`, `heading` and the matching `text`. Answer from them and name the notes you used. To read a whole note, call folder_read with its `file`.""" svc = _service(ctx) if svc is None: return {"error": "Searching your folders isn't available right now."} - return await svc.search(query, limit=limit or None, folder=folder) + scope = await memory_scopes.for_context(ctx) + return await svc.search(query, limit=limit or None, folder=folder, scope=scope, everywhere=bool(everywhere)) @tool("folder_read", risk=Risk.read, untrusted_output=False) -async def folder_read(ctx: ToolContext, path: str, offset: int = 0, limit: int = 0) -> dict: +async def folder_read(ctx: ToolContext, path: str, offset: int = 0, limit: int = 0, everywhere: bool = False) -> dict: """Read a whole note or document from the user's folders. Pass the `file` a folder_search result gave (its full - path), so the right folder is used. + path), so the right folder is used. Set everywhere=true for a note folder_search found with everywhere=true. A long one comes in parts: when `more` is true, call again with offset = `next_offset`.""" svc = _service(ctx) if svc is None: return {"error": "Reading your folders isn't available right now."} + scope = None if everywhere else await memory_scopes.for_context(ctx) try: - found = await svc.read(path) + found = await svc.read(path, scope) except (OSError, ValueError) as exc: # also a file that became a link out of the folder return {"error": f"That file can't be read: {exc}"} if found is None: + other = await svc.resolve(path) if scope is not None else None + if other is not None: # in a folder kept for other chats (#392) + return {"error": ( + f"That note is in a folder kept for {memory_scopes.label(folder_scope(other[0].get('scope')))} chats. " + "Read it with everywhere=true only if the user asked about it." + )} return {"error": "That isn't a file in the user's folders. Use the `file` a folder_search result gave."} folder, row, text = found budget = max(1000, part_budget(ctx) - 600) # room for the title, path and link around the part diff --git a/sentient/gateway/routes/folders.py b/sentient/gateway/routes/folders.py index abd8aa58f..34c284af4 100644 --- a/sentient/gateway/routes/folders.py +++ b/sentient/gateway/routes/folders.py @@ -15,10 +15,12 @@ class AddBody(BaseModel): path: str ignore: list[str] | None = None + scope: str | None = None # the chats its notes are used in (#392); always when left out class PatchBody(BaseModel): ignore: list[str] | None = None + scope: str | None = None class SearchBody(BaseModel): @@ -35,14 +37,14 @@ async def list_folders(request: Request): @router.post("") async def add_folder(request: Request, body: AddBody): try: - return await get_core(request).folders.add(body.path, body.ignore) + return await get_core(request).folders.add(body.path, body.ignore, body.scope) except FolderError as exc: raise HTTPException(400, str(exc)) from exc @router.patch("/{folder_id}") async def update_folder(request: Request, folder_id: str, body: PatchBody): - folder = await get_core(request).folders.update(folder_id, body.ignore) + folder = await get_core(request).folders.update(folder_id, body.ignore, body.scope) if folder is None: raise HTTPException(404, "no such folder") return folder diff --git a/sentient/memory/scopes.py b/sentient/memory/scopes.py index 59c6ade02..641ff9b19 100644 --- a/sentient/memory/scopes.py +++ b/sentient/memory/scopes.py @@ -143,8 +143,8 @@ def prompt_note(scope: str) -> str: async def list_scopes(app: Any) -> list[dict]: - """``[{name, label, count, built_in}]``: Always, Personal and Work, then every project a memory, chat, task or - messaging app uses (by name). ``count`` = active memories in it.""" + """``[{name, label, count, built_in}]``: Always, Personal and Work, then every project a memory, chat, task, + messaging app or added folder (#392) uses (by name). ``count`` = active memories in it.""" store = app.store counts: dict[str, int] = {} names: list[str] = [] @@ -160,6 +160,7 @@ async def list_scopes(app: Any) -> list[dict]: for sql in ( "SELECT DISTINCT memory_scope AS s FROM sessions WHERE memory_scope IS NOT NULL", "SELECT DISTINCT memory_scope AS s FROM tasks WHERE memory_scope IS NOT NULL", + "SELECT DISTINCT scope AS s FROM folders WHERE scope IS NOT NULL", ): with contextlib.suppress(Exception): # an older database without the column names += [n for r in await store.fetchall(sql) if (n := normalize(r["s"]))] diff --git a/sentient/store/db.py b/sentient/store/db.py index d09234beb..7866e1d95 100644 --- a/sentient/store/db.py +++ b/sentient/store/db.py @@ -27,11 +27,12 @@ # Feature packages own their tables. Each file must be idempotent. Order matters for foreign keys. # Bump when a change to a schema.sql or an ensure_column needs existing databases updated. Opening a database # with another version takes a snapshot first (issue #151); so does the first column added to an existing one. -SCHEMA_VERSION = 11 # 2: sessions.incognito (#154); 3: messages.sources (#290); 4: tasks.notepad (#292); 5: sessions.model (#312) +SCHEMA_VERSION = 12 # 2: sessions.incognito (#154); 3: messages.sources (#290); 4: tasks.notepad (#292); 5: sessions.model (#312) # 6: approval_grants (#139); 7: usage.cost_usd (#148); 8: evolution_reviews.activity_mark (#297) # 9: messages.approval and the activity timeline's indexes (#147); 10: try runs and file snapshots (#142) # 11: memory scopes: facts.scope, sessions.memory_scope, tasks.memory_scope, summaries.scope (#155); also # messages.checks and task_runs.checks (#165), which arrived without a bump +# 12: folders.scope (#392): folders added before it are recalled in every chat PACKAGE_SCHEMAS = [ "tasks", "integrations", "proactivity", "evolution", "voice", "memory", "nodes", "channels", "browser", "sandbox", "folders", "reminders", "snapshots", @@ -182,6 +183,7 @@ async def open(self) -> Store: extra = _PKG_ROOT / pkg / "schema.sql" if extra.exists(): await self._db.executescript(extra.read_text(encoding="utf-8")) + await self.ensure_column("folders", "scope", "TEXT NOT NULL DEFAULT 'always'") # #392, after folders/schema.sql await self._db.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") await self._db.commit() return self diff --git a/tests/channels/test_scope_command.py b/tests/channels/test_scope_command.py new file mode 100644 index 000000000..871a4c6db --- /dev/null +++ b/tests/channels/test_scope_command.py @@ -0,0 +1,74 @@ +"""/scope in a messaging app (#392): pick which memories a chat uses, answered without a model.""" + +from __future__ import annotations + +SISTER = "Maya's sister Meera lives in Lisbon" +KESTREL = "Maya leads Project Kestrel, a payments launch" + + +async def _scope_of(app, chat_id: str = "42") -> str | None: + chat = await app.channels.store.chat("telegram", chat_id) + return (await app.store.get_session(chat["session_id"]))["memory_scope"] + + +async def test_scope_shows_and_changes_what_the_chat_recalls(tg): + app = tg.app + app.config.memory.min_similarity = 0.0 + await app.memory.remember(SISTER, use_llm=False, scope="personal") + await app.memory.remember(KESTREL, use_llm=False, scope="work") + await tg.pair(42) + calls = len(app.llm.calls) + + await tg.say(42, "/scope") + assert tg.api.screen()[-1].startswith("This chat uses your Personal memories, plus the ones kept for every chat.") + await tg.say(42, "/scope work") + reply = tg.api.screen()[-1] + assert reply.startswith("Done. This chat now uses your Work memories") and "no longer brings up your Personal" in reply + assert await _scope_of(app) == "work" + await tg.say(42, "/scope Work") + assert tg.api.screen()[-1] == "This chat already uses your Work memories." + assert len(app.llm.calls) == calls # no model call for any of it + + app.llm.replies = ["You lead Kestrel."] + await tg.say(42, "what do you know about me?") + system = app.llm.calls[-1]["messages"][0]["content"] + assert KESTREL in system and SISTER not in system + + await tg.say(42, "/scope personal") + assert await _scope_of(app) == "personal" + app.llm.replies = ["Your sister lives in Lisbon."] + await tg.say(42, "what do you know about me?") + system = app.llm.calls[-1]["messages"][0]["content"] + assert SISTER in system and KESTREL not in system + + +async def test_projects_new_chats_default_and_always(tg): + app = tg.app + await app.memory.remember("Project Atlas ships the new onboarding flow", use_llm=False, scope="Atlas launch") + await tg.pair(42) + await tg.say(42, "/scope") + assert "Your projects: Atlas launch." in tg.api.screen()[-1] + await tg.say(42, "/scope atlas LAUNCH") # an existing project keeps its spelling + assert await _scope_of(app) == "Atlas launch" + await tg.say(42, "/scope Garden") + assert "Garden is a new project, so it has no memories yet." in tg.api.screen()[-1] + await tg.say(42, "/scope always") + assert "can't use only the memories kept for every chat" in tg.api.screen()[-1] + assert await _scope_of(app) == "Garden" + + await tg.say(42, "/new") # a fresh chat keeps the scope + assert await _scope_of(app) == "Garden" + + app.config.channels.telegram.memory_scope = "work" + await tg.say(42, "/scope default") # follows the app's setting again + assert await _scope_of(app) is None + assert tg.api.screen()[-1].startswith("Done. This chat now uses your Work memories") + await tg.say(42, "/help") + assert "/scope" in tg.api.screen()[-1] + + +async def test_unpaired_chats_cannot_change_a_scope(tg): + await tg.pair(42) + await tg.say(7, "/scope work") + assert "isn't paired" in tg.api.screen()[-1] + assert await _scope_of(tg.app) is None diff --git a/tests/folders/test_folder_scopes.py b/tests/folders/test_folder_scopes.py new file mode 100644 index 000000000..4ca9eae5b --- /dev/null +++ b/tests/folders/test_folder_scopes.py @@ -0,0 +1,190 @@ +"""Folder notes follow memory scopes (#392): a folder kept for Work only comes up in work chats, Always (the default, +also for folders added before scopes) everywhere. Offline: FakeProvider makes the embeddings.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from sentient.app import SentientApp +from sentient.gateway.app import create_app +from sentient.memory import scopes +from sentient.store.db import SCHEMA_VERSION +from tests.conftest import FakeProvider, tool_call +from tests.folders.test_prompt_notes import topic_embed + +KESTREL = "Kestrel launch checklist: the bank partner demo is on Friday the 23rd. Arjun signs off the launch.\n" +LEASE = "Flat lease for 304, Lavender Court. Rent 38,000 rupees a month. Lease ends 31 May 2027.\n" + + +@pytest.fixture +def work(tmp_path) -> Path: + d = tmp_path / "Work notes" + d.mkdir() + (d / "kestrel.txt").write_text(KESTREL, encoding="utf-8") + return d + + +@pytest.fixture +def home_docs(tmp_path) -> Path: + d = tmp_path / "Home docs" + d.mkdir() + (d / "lease.txt").write_text(LEASE, encoding="utf-8") + return d + + +@pytest.fixture +async def app(config, isolated_home): + llm = FakeProvider() + llm.embed = topic_embed + a = SentientApp(config, llm=llm, db_path=isolated_home / "scoped.db", enable_background=False) + await a.start() + a.fake = llm + yield a + await a.stop() + + +async def _add(app, path: Path, scope: str | None = None) -> dict: + folder = await app.folders.add(str(path), scope=scope) + await app.folders.idle() + return folder + + +async def test_a_new_folder_is_used_everywhere_until_given_a_scope(app, work): + folder = await _add(app, work) + assert folder["scope"] == "always" + for scope in (None, "personal", "work", "Atlas"): + hits = (await app.folders.search("Kestrel bank demo", scope=scope))["results"] + assert [h["path"] for h in hits] == ["kestrel.txt"], scope + changed = await app.folders.update(folder["id"], None, "Work") + assert changed["scope"] == "work" + assert {s["name"] for s in await scopes.list_scopes(app)} >= {"always", "personal", "work"} + await app.folders.update(folder["id"], None, "Kestrel launch") + assert "Kestrel launch" in [s["name"] for s in await scopes.list_scopes(app)] # a project a folder uses is listed + await app.folders.update(folder["id"], None, "") + assert (await app.folders.get(folder["id"]))["scope"] == "always" + + +async def test_a_work_folder_stays_out_of_personal_chats(app, work, home_docs): + await _add(app, work, "work") + await _add(app, home_docs, "personal") + + assert [h["path"] for h in (await app.folders.search("Kestrel bank demo", scope="work"))["results"]] == ["kestrel.txt"] + personal = await app.folders.search("Kestrel bank demo", scope="personal") + assert all(h["path"] != "kestrel.txt" for h in personal["results"]) + assert all(h["path"] != "lease.txt" for h in (await app.folders.search("lease rent flat", scope="work"))["results"]) + + # asked directly: everywhere, and the result says where it is kept + wide = await app.folders.search("Kestrel bank demo", scope="personal", everywhere=True) + assert wide["results"][0]["path"] == "kestrel.txt" and wide["results"][0]["kept_for"] == "Work" + assert "kept_for" not in (await app.folders.search("Kestrel bank demo", scope="work"))["results"][0] + # work not tied to a chat reads every folder + assert {h["path"] for h in (await app.folders.search("Kestrel lease", scope=None))["results"]} >= {"kestrel.txt"} + + # note pieces for the prompt follow the chat's scope + assert await app.folders.prompt_pieces("when does my lease end?", "work") == [] + assert [p["path"] for p in await app.folders.prompt_pieces("when does my lease end?", "personal")] == ["lease.txt"] + # a project chat reads only always folders and its own + assert await app.folders.prompt_pieces("when does my lease end?", "Atlas") == [] + + +async def test_only_folders_of_another_scope_say_so(app, work): + await _add(app, work, "work") + out = await app.folders.search("Kestrel", scope="personal") + assert out["results"] == [] and "kept for other chats (Work)" in out["note"] and "everywhere=true" in out["note"] + + +async def test_chats_and_tools_use_the_chats_scope(app, work, home_docs): + await _add(app, work, "work") + await _add(app, home_docs, "personal") + work_chat = await app.store.create_session(channel="desktop", memory_scope="work") + home_chat = await app.store.create_session(channel="desktop") + + _, _, notes = await app.agent._system_prompt_with_sources( + "when does my lease end?", "desktop", await app.store.get_session(home_chat)) + assert [n["path"] for n in notes] == ["lease.txt"] + prompt, _, notes = await app.agent._system_prompt_with_sources( + "when does my lease end?", "desktop", await app.store.get_session(work_chat)) + assert notes == [] and "Lavender Court" not in prompt + + search, read = app.registry.get("folder_search"), app.registry.get("folder_read") + ctx = app.agent.tool_context(work_chat, "desktop") + found = await search.call(ctx, {"query": "Kestrel bank demo"}) + assert [r["path"] for r in found["results"]] == ["kestrel.txt"] + assert all(r["path"] != "lease.txt" for r in (await search.call(ctx, {"query": "lease rent flat"}))["results"]) + lease_file = str(home_docs / "lease.txt") + refused = await read.call(ctx, {"path": lease_file}) + assert "kept for Personal chats" in refused["error"] + assert "Lavender Court" in (await read.call(ctx, {"path": lease_file, "everywhere": True}))["content"] + assert "Kestrel" in (await read.call(ctx, {"path": str(work / "kestrel.txt")}))["content"] + + # a real turn in the work chat that searches: only the work folder answers + app.fake.replies.extend([[tool_call("folder_search", query="lease rent")], "Nothing in your work notes."]) + _ = [ev async for ev in app.agent.run_turn(work_chat, "what do my notes say about rent?", channel="desktop")] + tool_msg = next(m for m in app.fake.calls[-1]["messages"] if m.get("role") == "tool") + assert "Lavender Court" not in tool_msg["content"] + + +def test_folder_scope_over_rest(config, isolated_home, monkeypatch, work): + monkeypatch.setenv("SENTIENT_GATEWAY_TOKEN", "test-token") + core = SentientApp(config, llm=FakeProvider(), db_path=isolated_home / "r.db", enable_background=False) + with TestClient(create_app(core)) as c: + c.headers.update({"Authorization": "Bearer test-token"}) + added = c.post("/api/folders", json={"path": str(work), "scope": "work"}).json() + assert added["scope"] == "work" + fid = added["id"] + assert c.patch(f"/api/folders/{fid}", json={"scope": "Kestrel"}).json()["scope"] == "Kestrel" + assert c.get("/api/folders").json()["folders"][0]["scope"] == "Kestrel" + assert "Kestrel" in [s["name"] for s in c.get("/api/memories/scopes").json()] + assert c.patch(f"/api/folders/{fid}", json={"scope": "always"}).json()["scope"] == "always" + assert c.patch("/api/folders/nope", json={"scope": "work"}).status_code == 404 + + +async def test_folders_added_before_scopes_are_used_everywhere(config, isolated_home, work): + isolated_home.mkdir(parents=True, exist_ok=True) + db = isolated_home / "old.db" + con = sqlite3.connect(db) + con.executescript( + "CREATE TABLE folders (id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL," + " kind TEXT NOT NULL DEFAULT 'folder', ignore TEXT NOT NULL DEFAULT '[]', added_at TEXT NOT NULL," + " last_scan_at TEXT, last_scan_ms INTEGER, skipped INTEGER NOT NULL DEFAULT 0, error TEXT);" + ) + con.execute("INSERT INTO folders(id, path, name, added_at) VALUES('f1', ?, 'Work notes', '2026-10-01T00:00:00')", + (str(work),)) + con.execute("PRAGMA user_version = 11") + con.commit() + con.close() + a = SentientApp(config, llm=FakeProvider(), db_path=db, enable_background=False) + await a.start() + try: + [folder] = (await a.folders.overview())["folders"] + assert folder["scope"] == "always" + finally: + await a.stop() + con = sqlite3.connect(db) + assert con.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION + con.close() + + +async def test_a_closer_note_in_another_scope_never_hides_this_ones(app, home_docs, tmp_path): + office = tmp_path / "Office" + office.mkdir() + (office / "office lease.txt").write_text("Office lease: lease rent, lease renewal, lease rent lease.\n", encoding="utf-8") + for i in range(70): # more matching pieces than one search looks at + (office / f"desk {i}.txt").write_text(f"Desk {i} lease and rent for the office lease.\n", encoding="utf-8") + await _add(app, office, "work") + await _add(app, home_docs, "personal") + wide = await app.folders.search("lease rent", scope=None) + assert "lease.txt" not in [r["path"] for r in wide["results"]] # the office notes fill a search of every folder + personal = await app.folders.search("lease rent", scope="personal") + assert [r["path"] for r in personal["results"]] == ["lease.txt"] + assert [p["path"] for p in await app.folders.prompt_pieces("when does my lease rent end?", "personal")] == ["lease.txt"] + + # nothing here matches: the note names the folders left out, never what is in them + out = await app.folders.search("orchard apple trees", scope="personal") + assert out["results"] == [] and "Folders kept for Work chats weren't searched" in out["note"] + out = await app.folders.search("Office lease", scope="personal", folder="Office") + assert all(r["path"] != "office lease.txt" for r in out["results"]) From 2ced5882c03ff3e2cf6f8f95f1eba93cd87cc03c Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Fri, 16 Oct 2026 19:38:37 +0530 Subject: [PATCH 2/2] fix(folders): filter scoped searches before the candidate limit, reuse a project's spelling Keyword search filters to the searched folders in SQL and the vector look asks for more neighbours until enough are in those folders. A folder scope and /scope reuse the spelling of a project already in use. --- sentient/channels/base.py | 4 ++-- sentient/folders/service.py | 35 +++++++++++++++++++++-------- sentient/memory/scopes.py | 10 +++++++++ tests/folders/test_folder_scopes.py | 15 +++++++++++-- 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/sentient/channels/base.py b/sentient/channels/base.py index a11b480c0..12645aea6 100644 --- a/sentient/channels/base.py +++ b/sentient/channels/base.py @@ -1031,13 +1031,13 @@ async def _scope_command(self, chat: dict, value: str) -> None: scope = None else: try: - scope = memory_scopes.chat_scope(value) + memory_scopes.chat_scope(value) except ValueError: await self.reply(chat_id, "A chat can't use only the memories kept for every chat. " "Send /scope personal, /scope work or /scope followed by a project's name.") return # an existing project keeps its spelling: "/scope kestrel launch" is "Kestrel launch" - scope = next((n for n in projects if scope and n.lower() == scope.lower()), scope) + scope = await memory_scopes.resolve(self.app, value) await self.app.store.set_session_scope(session_id, scope) now = memory_scopes.of_session(self.app.config, {**session, "memory_scope": scope}) self.app.bus.publish("session.updated", {"session_id": session_id, "memory_scope": now}) diff --git a/sentient/folders/service.py b/sentient/folders/service.py index 72ff0148a..ecfc37198 100644 --- a/sentient/folders/service.py +++ b/sentient/folders/service.py @@ -55,6 +55,7 @@ EMBED_EVERY_FILES = 25 # embed what is waiting after this many files, so search by meaning improves during a long scan PUBLISH_EVERY_S = 1.0 CANDIDATES = 60 # pieces each search method proposes before they are merged +MAX_NEIGHBOURS = 4096 # the most neighbours one look in the vector index asks for (sqlite-vec's limit for k) RRF_K = 60 PER_FILE = 2 # most pieces of one file in one search's results MIN_SIMILARITY = 0.3 @@ -250,6 +251,7 @@ async def add(self, path: str, ignore: list[str] | None = None, scope: str | Non p = self._check_new(path, await self._rows()) fid = new_id() kind = "obsidian" if is_obsidian(p) else "folder" + scope = await memory_scopes.resolve(self.app, scope, memory_scopes.ALWAYS) # an existing project's spelling await self.app.store.execute( "INSERT INTO folders(id, path, name, kind, ignore, added_at, scope) VALUES(?,?,?,?,?,?,?)", (fid, str(p), p.name or str(p), kind, json.dumps(_clean_patterns(ignore)), now_iso(), folder_scope(scope)), @@ -265,6 +267,8 @@ async def update(self, folder_id: str, ignore: list[str] | None, scope: str | No row = await self._row(folder_id) if row is None: return None + if scope is not None: + scope = await memory_scopes.resolve(self.app, scope, memory_scopes.ALWAYS) # an existing project's spelling if scope is not None and folder_scope(scope) != folder_scope(row.get("scope")): await self.app.store.execute("UPDATE folders SET scope = ? WHERE id = ?", (folder_scope(scope), folder_id)) log.info("folder %s is now used in %s chats", row["path"], folder_scope(scope)) @@ -627,21 +631,22 @@ async def _search( # only some folders (a scope, #392, or one folder by name): drop the others' pieces before ranking, so a # better match in a folder that isn't searched never pushes this one's out of the results or the window only = set(allowed) if len(allowed) < len(every) else None - k = CANDIDATES * 4 if only else CANDIDATES scores: dict[int, float] = {} similarity: dict[int, float] = {} keyword_rank: dict[int, int] = {} words = content_words(query) # the same words prompt_pieces checks, each once if words: match = " OR ".join('"' + w.replace('"', "") + '"' for w in words) + within, params = "", [match] + if only: # only pieces of the folders searched, before the limit + within = (" AND rowid IN (SELECT id FROM folder_chunks WHERE folder_id IN" + f" ({','.join('?' * len(only))}))") + params += sorted(only) try: fts = await self.app.store.fetchall( "SELECT rowid, bm25(folder_fts, 4.0, 2.0, 1.0) AS b FROM folder_fts WHERE folder_fts MATCH ?" - " ORDER BY b LIMIT ?", (match, k), + f"{within} ORDER BY b LIMIT ?", (*params, CANDIDATES), ) - if only: - keep = await self._in_folders([int(r[0]) for r in fts], only) - fts = [r for r in fts if int(r[0]) in keep][:CANDIDATES] except Exception as exc: log.debug("keyword search failed: %s", exc) fts = [] @@ -655,10 +660,7 @@ async def _search( if await self._vec_ready(): try: qv = (await self.app.llm.embed([query]))[0] - hits = await self.vec.knn(qv, k) - if only: - keep = await self._in_folders([cid for cid, _ in hits], only) - hits = [h for h in hits if h[0] in keep][:CANDIDATES] + hits = await self._nearest(qv, only) except asyncio.CancelledError: raise except Exception as exc: @@ -711,6 +713,21 @@ async def _search( result["results"] = out return result, by_meaning + async def _nearest(self, qv: list[float], only: set[str] | None) -> list[tuple[int, float]]: + """The ``CANDIDATES`` pieces closest in meaning, of the folders in ``only`` (None: every folder). The vector + index can't filter by folder, so it asks for more neighbours until enough of them are in those folders, or + there are no more (at most ``MAX_NEIGHBOURS``).""" + if only is None: + return await self.vec.knn(qv, CANDIDATES) + k = CANDIDATES * 4 + while True: + hits = await self.vec.knn(qv, k) + keep = await self._in_folders([cid for cid, _ in hits], only) + mine = [h for h in hits if h[0] in keep] + if len(mine) >= CANDIDATES or len(hits) < k or k >= MAX_NEIGHBOURS: + return mine[:CANDIDATES] + k = min(k * 4, MAX_NEIGHBOURS) + async def _in_folders(self, chunk_ids: list[int], folder_ids: set[str]) -> set[int]: """The pieces of ``chunk_ids`` that belong to one of ``folder_ids``.""" if not chunk_ids or not folder_ids: diff --git a/sentient/memory/scopes.py b/sentient/memory/scopes.py index 641ff9b19..220d8f9f3 100644 --- a/sentient/memory/scopes.py +++ b/sentient/memory/scopes.py @@ -142,6 +142,16 @@ def prompt_note(scope: str) -> str: ) +async def resolve(app: Any, raw: Any, default: str | None = None) -> str | None: + """:func:`normalize`, then the spelling of a project already in use when one matches whatever the case ("atlas" + is "Atlas" when a memory, chat, task, messaging app or folder uses "Atlas"), so one project keeps one name.""" + scope = normalize(raw, default) + if not scope or scope in BUILT_IN: + return scope + want = scope.lower() + return next((s["name"] for s in await list_scopes(app) if s["name"].lower() == want), scope) + + async def list_scopes(app: Any) -> list[dict]: """``[{name, label, count, built_in}]``: Always, Personal and Work, then every project a memory, chat, task, messaging app or added folder (#392) uses (by name). ``count`` = active memories in it.""" diff --git a/tests/folders/test_folder_scopes.py b/tests/folders/test_folder_scopes.py index 4ca9eae5b..527160074 100644 --- a/tests/folders/test_folder_scopes.py +++ b/tests/folders/test_folder_scopes.py @@ -10,6 +10,7 @@ from fastapi.testclient import TestClient from sentient.app import SentientApp +from sentient.folders import service as folder_service from sentient.gateway.app import create_app from sentient.memory import scopes from sentient.store.db import SCHEMA_VERSION @@ -169,11 +170,12 @@ async def test_folders_added_before_scopes_are_used_everywhere(config, isolated_ con.close() -async def test_a_closer_note_in_another_scope_never_hides_this_ones(app, home_docs, tmp_path): +async def test_a_closer_note_in_another_scope_never_hides_this_ones(app, home_docs, tmp_path, monkeypatch): + monkeypatch.setattr(folder_service, "CANDIDATES", 5) # so 30 notes are many more than 4 times what one look takes office = tmp_path / "Office" office.mkdir() (office / "office lease.txt").write_text("Office lease: lease rent, lease renewal, lease rent lease.\n", encoding="utf-8") - for i in range(70): # more matching pieces than one search looks at + for i in range(30): # more matching pieces than one search looks at (office / f"desk {i}.txt").write_text(f"Desk {i} lease and rent for the office lease.\n", encoding="utf-8") await _add(app, office, "work") await _add(app, home_docs, "personal") @@ -188,3 +190,12 @@ async def test_a_closer_note_in_another_scope_never_hides_this_ones(app, home_do assert out["results"] == [] and "Folders kept for Work chats weren't searched" in out["note"] out = await app.folders.search("Office lease", scope="personal", folder="Office") assert all(r["path"] != "office lease.txt" for r in out["results"]) + + +async def test_a_folder_reuses_the_spelling_of_a_project_in_use(app, work): + await app.memory.remember("Project Atlas ships the new onboarding flow", use_llm=False, scope="Atlas") + folder = await _add(app, work, "atlas") + assert folder["scope"] == "Atlas" + assert (await app.folders.update(folder["id"], None, "ATLAS"))["scope"] == "Atlas" + assert [s["name"] for s in await scopes.list_scopes(app)].count("Atlas") == 1 + assert [h["path"] for h in (await app.folders.search("Kestrel bank demo", scope="Atlas"))["results"]] == ["kestrel.txt"]