diff --git a/README.md b/README.md index 089f3b3a..8f6618de 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Self-hosted autonomous AI agent platform for a single owner, with a sandboxed wo AgentStudio provides a streaming chat interface where the assistant can call tools such as web search and sandboxed code execution. The filesystem toolset supports ranged file reads, full writes, unified-diff patch apply, deterministic string replace, recursive directory listing, search, move/rename, delete, and file metadata lookups. Chat supports editing and branching, interleaved tool and thinking blocks, per-message performance and cost metrics, model selection, and per-prompt reasoning effort selection. +Conversations can be pinned to the top of the sidebar, archived (the everyday way to tidy the list: hidden, never deleted, restored from the Archived view or by replying), renamed, deleted behind a confirmation, and exported as a Markdown transcript or a complete JSON file. The sidebar's search box also searches the whole history on the server — message text and the work each turn did (file paths, commands, links a tool printed) — and shows highlighted extracts. See [docs/chat/chat.md](docs/chat/chat.md). + Shell commands render as a small terminal: the command, its output in a fixed-width font with colour codes cleaned out, the last 20 lines first with a control to show the rest, a copy button, and a badge for how it ended (such as `exit 2`). A command the agent runs in the background (a dev server, a long build) streams its output into that card while the turn runs, shows as a header chip with a stop button, and ends when the reply does; the card and the agent both say so. Deleting a conversation stops its running turn and those commands first. See [docs/chat/spec.md](docs/chat/spec.md#command-output). Editing a message or regenerating a reply cuts the conversation back so the model sees only what was kept plus the message it is answering. In a project (or an agent with a persistent workspace) the agent's file changes are checkpointed every turn, and Edit and Regenerate offer to restore the files the dropped replies changed — showing the files and line counts first, and asking again before overwriting uncommitted changes in an imported repository. **Compact Conversation** runs the SDK's own `/compact`. See [docs/chat/chat.md](docs/chat/chat.md). @@ -219,7 +221,7 @@ Notes: - Tools (what agents can call, approvals, code execution): `docs/tools/tools.md` - Chat plan: `docs/chat/plan.md` - Chat spec (composer shortcuts, attachments, safe rendering): `docs/chat/spec.md` -- Chat: editing, regenerating, restoring files and compacting: `docs/chat/chat.md` +- Chat (conversations: pin, archive, search, export; editing, regenerating, restoring files and compacting): `docs/chat/chat.md` - Models, the subscription and the optional gateway: `docs/llm/llm.md` - Memory spec: `docs/memory/spec.md` - Automations: `docs/automations/automations.md` @@ -278,6 +280,7 @@ bun run bench:longmemeval:smoke --dataset=oracle --limit=5 - `/setup` First-run owner account creation (only until an owner exists; asks for the setup token on a production build) - `/chat` Conversations - `/chat/[id]` Chat detail, with the Preview + Files rail ([docs](docs/chat-console/chat-console.md)) +- `GET /chat/[id]/export?format=md|json` Download one conversation as a Markdown transcript or complete JSON ([docs](docs/chat/chat.md#export-a-conversation)) - `/runs/[id]` One run's event timeline: every tool call, result and approval ([docs](docs/runs/spec.md)) - `/activity` Usage strip (runs, tokens, tools, budget headroom, anomalies) above the activity feed ([docs](docs/activity/spec.md)) - `/review` Cost, recent failures, logs and the review inbox diff --git a/docs/chat-console/chat-console.md b/docs/chat-console/chat-console.md index b39db617..29e2f4a5 100644 --- a/docs/chat-console/chat-console.md +++ b/docs/chat-console/chat-console.md @@ -27,6 +27,34 @@ Opening another chat from the sidebar gives that chat a fresh page. Nothing the chat was doing (a reply streaming in, its tool cards, its Stop button, an error and its Retry) carries over. See "Switching conversations mid-turn" in the chat spec. +### Pinned, archived, searched (#18) + +The rules behind these are in the [chat domain doc](../chat/chat.md); this is how the +sidebar presents them. + +- **Pinned chats** sit in their own **Pinned** group above everything else, whichever way + the list is grouped (date, project, none), most recently pinned first. The rest of the + list is ordered by last activity. +- **Each row has a "⋯" menu**: Archive, Pin to top / Unpin, Rename, Export as Markdown, + Export as JSON and, last and in red, Delete… (which asks first). On a computer the menu + button and a one-click **Archive** button appear when the pointer is over the row or a + row control has keyboard focus, covering the time label; on a touch screen or in the + phone's navigation drawer the "⋯" button is always shown and the row keeps room for it, + so the title never collapses. The menu opens inline under its row, so neither the + scrolling list nor the drawer can cut it off. Clicking anywhere else, or Escape, closes it. +- **The Status filter** (in the menu under the search box) has three views: **All** (every + chat that is not archived), **Running** (chats with a turn in progress) and **Archived**. + The Archived view shows a bar with a **Back to chats** link, and "Loading…" until the + archive has arrived. It is ordered, grouped by day and labelled by when each chat was + archived rather than by its last activity. ("Archived" used to mean "not running", and + "Active" meant running; both were renamed when the real archive arrived.) +- **The search box** filters the loaded list by title and latest reply as you type. After a + quarter-second pause it also searches the whole history on the server, and matches in + messages and tool calls appear under **In messages**: title, an *archived* badge where it + applies, when the matching message was written, and up to three lines of extract with the + matched words highlighted. The extract is always shown as text; the highlighting is added + by the page. Pinning, archiving, renaming or deleting a chat re-runs the search. + ## The right rail The rail sits to the right of a conversation. It only appears on a chat: the home page diff --git a/docs/chat/chat.md b/docs/chat/chat.md index 183ab23f..5b3bfd03 100644 --- a/docs/chat/chat.md +++ b/docs/chat/chat.md @@ -2,22 +2,46 @@ ## Overview -Chat is where you talk to an agent. Each conversation is a list of messages in AgentStudio's database, and behind it sits a Claude Agent SDK session: the SDK's own record of the conversation, which is what the model actually reads on every turn. The full feature spec (composer, attachments, tool cards, approvals, the right rail and so on) is [spec.md](spec.md). This page covers the parts of chat that change a conversation after the fact: +Chat is where the owner works with AgentStudio's agents. A **conversation** is one thread: a +title, the agent it is bound to, and an ordered history of messages. Each message the user +sends starts a **turn**, in which the agent answers and may use tools — read and edit files, +run commands, search the web, hand work to a subagent. Everything the turn did is kept on the +reply, so a conversation can be reread, searched and exported later. -- **Edit** a message you sent, and get a new reply to the edited text. -- **Regenerate** the last reply. -- **Also restore files**: when you edit or regenerate, put the files the dropped replies changed back the way they were (#24). -- **Compact** a long conversation so it takes less of the model's context. +Each conversation is a list of messages in AgentStudio's database, and behind it sits a +Claude Agent SDK session: the SDK's own record of the conversation, which is what the model +actually reads on every turn. + +This document covers two things: + +- **Looking after the list of conversations** (#18): pinning, archiving, renaming, deleting, + searching and exporting. +- **Changing a conversation after the fact** (#24): + - **Edit** a message you sent, and get a new reply to the edited text. + - **Regenerate** the last reply. + - **Also restore files**: when you edit or regenerate, put the files the dropped replies + changed back the way they were. + - **Compact** a long conversation so it takes less of the model's context. + +The live chat screen (the composer, streaming, approvals, the right rail) is described in +[chat-console](../chat-console/chat-console.md), and the full feature spec (composer, +attachments, tool cards, approvals and so on) in [spec.md](spec.md). + +Audience: the owner, and anyone working on the chat code (`src/lib/chat/`). ## Key concepts -| Concept | What it means | +| Concept | What it is | | --- | --- | -| Message row | One message in the `messages` table: your text, or the agent's reply. What the page shows. | -| SDK session | The Claude Agent SDK's transcript of the conversation (`conversations.sdk_session_id`). What the model sees. Each turn resumes it. | -| Transcript join | Where a message row sits in the SDK transcript, recorded on the row's `metadata` (see below). How an edit knows where to cut, and how a restore knows which checkpoint to go back to. | -| File checkpoint | A backup the SDK takes of a file just before the agent's file tools (Write, Edit, MultiEdit, NotebookEdit) change it, keyed by the message that started the turn. | -| Restore | Putting files back to how they were at one of your messages, from those backups. Files the agent created after that point are deleted. | +| **Conversation** | One chat thread. Has a title, an agent, a model, token and cost totals, and a *last activity* time that orders the list. | +| **Message** | One entry in a conversation (a row in the `messages` table): from the user, the assistant, or the system (a note such as "the conversation switched agents here"). What the page shows. An assistant message carries the **blocks** of its turn: text, thinking, tool calls with their arguments and results, subagent work and run notices. | +| **Pinned** | A conversation kept at the top of the sidebar. The pin records *when* it was pinned. | +| **Archived** | A conversation put away: hidden from the normal list, but kept whole. The archive records *when* it was archived. | +| **Search index** | For every message, the text it can be found by — see "Search conversations" below. Kept in its own table beside the messages. | +| **SDK session** | The Claude Agent SDK's transcript of the conversation (`conversations.sdk_session_id`). What the model sees. Each turn resumes it. | +| **Transcript join** | Where a message row sits in the SDK transcript, recorded on the row's `metadata` (see below). How an edit knows where to cut, and how a restore knows which checkpoint to go back to. | +| **File checkpoint** | A backup the SDK takes of a file just before the agent's file tools (Write, Edit, MultiEdit, NotebookEdit) change it, keyed by the message that started the turn. | +| **Restore** | Putting files back to how they were at one of your messages, from those backups. Files the agent created after that point are deleted. | ### The transcript join @@ -33,12 +57,109 @@ AgentStudio chooses the id each message carries into the transcript, rather than ## User flows +The first six flows start from the **"⋯" button on a conversation in the sidebar** (on a +phone, in the navigation drawer). On a computer the button, and a one-click **Archive** +button, appear when the pointer is over the row; on a touch screen the "⋯" button is always +there. The menu opens just below the row. Editing, regenerating and compacting happen in the +open conversation. + +### Pin a conversation + +1. Open the menu and choose **Pin to top**. +2. The conversation moves into a **Pinned** group above all the others. It stays there + however old it gets, whichever way the list is grouped. The most recently pinned + conversation is first in the group, whatever each one's last activity. +3. **Unpin** in the same menu puts it back in date order. + +### Archive a conversation (the everyday way to tidy up) + +1. Click the **Archive** button on the row, or choose **Archive** from the menu. +2. The conversation leaves the list. Nothing is deleted: its messages, run history, costs and + the memories mined from it all stay. +3. To find it again, open the filter menu under the search box and set **Status** to + **Archived**. The list shows archived conversations, most recently archived first, with a + **Back to chats** link to return. In this view everything goes by when a conversation was + archived, not when it was last used: grouped by date, one archived today sits under + "Today" even if nobody had touched it for a month, and the time on each row is how long + ago it was archived. +4. Choose **Unarchive** from its menu to put it back. It returns to its old place in the list. + +A conversation also comes back on its own when the owner **sends a message in it** (for +example, by opening it from a search result and replying). + +### Rename a conversation + +1. Choose **Rename** from the menu. +2. Edit the title in the box that appears and press Enter (or **Save**). Escape cancels. + +### Delete a conversation + +1. Choose **Delete…** — the last item in the menu, in red. +2. A confirmation explains what is lost and suggests archiving instead. Confirm with + **Delete**. +3. If the agent is still working in that conversation, its turn is stopped first, exactly + as the **Stop** button would stop it — along with any commands it left running in the + background and any subagents it delegated to — and the delete waits up to ten seconds + for it to finish saving what it had done. Then the conversation is deleted. +4. If that conversation was open, the app goes to the new-chat page. + +### Search conversations + +1. Type in the **Search** box at the top of the sidebar. +2. The list filters instantly by title and latest reply, among the conversations already + loaded. +3. A quarter of a second after typing stops (two characters or more), the server searches the + whole history. Results appear under **In messages**: the conversation's title, an + *archived* badge where it applies, when the matching message was written, and an extract + with the matching words highlighted. A short message (up to 100 characters, such as + "fix the login bug now") is shown whole; a longer one shows up to two short excerpts, + each with the words either side of a match. Click one to open that conversation. +4. While the Archived view is open, search covers archived conversations too. + +What search finds: + +- the text of every message, the owner's and the assistant's (an edited message by what it + says now); +- attachment file names; +- **the work each turn did**: for every tool call, the tool's name, the file paths it read or + changed, the commands it ran and their descriptions, short arguments (titles, names, + queries), and web links it printed — so "the run where it touched `options.server.ts`" or + "the one where it opened that pull request" can be found even though neither was ever said + in words; +- what a subagent was asked to do, the start of its answer (or its final report), and the + tools it called with the files and commands they touched — so work the agent delegated is + found the same way as work it did itself; +- conversation titles. + +A file can be found by its full path (`src/lib/engine/options.server.ts`), by part of it +(`engine/options.server.ts`), by its name (`options.server.ts`) or by one piece of it +(`options`). Results start appearing while a word is still being typed (`optio` finds +`options`). Quotes search for an exact phrase, `or` finds either word, and a leading `-` +excludes a word. + +What search deliberately does not look at: the full output of tools (a file that was read, a +long command output), the contents of files that were written or edited, the model's private +thinking, and system notes. + +### Export a conversation + +1. Choose **Export as Markdown** or **Export as JSON** from the menu. +2. The file downloads, named after the conversation and the date (for example + `fix-the-login-page-2026-09-23.md`). Browsers that support it use the readable title + instead, accents and emoji included, cut to its first 80 characters; a cut never splits + an emoji in half. + +| Format | What it is for | What it contains | +| --- | --- | --- | +| **Markdown** | Reading, pasting into notes or an issue, handing to another model | A header (dates, agent, model, tokens, cost), then each message in order. Edits appear as diffs, commands with their output, checklists as checklists, subagent work as quotes (with the subagent's own tool calls, and whether it was stopped), the model's thinking folded away. Very long tool output is shortened, with a note pointing to the JSON export. | +| **JSON** | Keeping a complete copy, or reading it with a program | Everything, unshortened: the conversation's details and every message with all of its blocks, tool arguments and results. The owner's account id is left out. | + ### Editing a message 1. You click the pencil on one of your messages, change the text, and click **Save & regenerate**. (The pencil is hidden while a reply is being written.) 2. If the dropped replies changed files that can be restored, the **restore dialog** opens (below). Otherwise the edit goes straight on. 3. If you chose to restore files, the files are restored first. If that fails, nothing else happens: the conversation is left exactly as it was and the error says so, so you can try again or go on without restoring. -4. Your message is updated and everything after it is deleted. +4. Your message is updated and everything after it is deleted. Search finds the message by its new text from then on. 5. A new reply is written. The model sees the conversation up to the reply before your message, followed by your edited text — nothing from the replies you dropped. If step 5 never starts — the connection drops, the server fails or restarts before the reply begins — the edit is not lost. The message is marked as cut (`sdkCutPending`). Whatever you do next, **Retry** or simply sending a new message, cuts the session back the same way first. The model then sees the conversation as the page shows it, never the replies you dropped. @@ -71,7 +192,17 @@ When you switch to a model with a smaller context window and the conversation wo Before this change both sent an ordinary message asking the model for a summary. The session then carried that summary on top of the full history, so the context grew instead of shrinking, and the notice claimed a compaction that had not happened. -## Roles & permissions +## Roles and permissions + +AgentStudio has a single owner. Every action here is limited to the signed-in owner's own +conversations: + +- Pinning, archiving, renaming and deleting refuse a conversation that is not theirs. +- Search only ever returns their own conversations. +- An export link for a conversation that is not theirs answers "not found" — the same as a + conversation that does not exist, so a link reveals nothing about whether it is real. +- Without a session, none of this is reachable: the pages redirect to sign-in, and the + export download is refused. | Action | Who can do it | | --- | --- | @@ -79,25 +210,59 @@ Before this change both sent an ordinary message asking the model for a summary. | Preview or restore files | The conversation's owner, for a message in their own conversation, in a working directory inside their own sandbox | | Restore over uncommitted changes in an imported repository | The owner, after ticking the explicit overwrite box; the server checks it again | -## Integrations - -**Claude Agent SDK** (`@anthropic-ai/claude-agent-sdk` 0.3.278). What each piece relies on, and how it was verified rather than assumed: - -| Behaviour | How it was checked | -| --- | --- | -| A message sent with our own id keeps that id in the transcript, and file checkpoints are keyed by it | Ran the bundled CLI against a stand-in Messages API: the transcript's user entries and `file-history-snapshot` entries carried exactly the ids we sent. | -| `enableFileCheckpointing` backs up files the Write tool changes, and `rewindFiles(id, { dryRun: true })` lists them (absolute paths) with line counts, without touching them | Same run: a file created in turn 2 was listed by a dry run to turn 2, and both turns' files by a dry run to turn 1. | -| `resume` + `resumeSessionAt` keeps the same session id and drops everything after the cut from what the model is sent | Same run: the model received turn 1 and the edited text, and nothing from turn 2. | -| After such a cut, the dropped message's checkpoint is gone and the kept ones still work | Same run. This is why files are restored *before* the new reply, never after. | -| A session with no prompt answers `rewindFiles` from the history it loads at start-up, with no model call | Automated: `tests/engine.rewind.spec.ts` runs the real CLI against a synthetic session on disk. | -| `/compact` sent as a plain-text message runs the SDK's compaction; the summary it writes after the boundary is a valid place to cut an edit | Stand-in API run: a `compact_boundary` came back, the next turn sent only the summary, and an edit cut after the summary kept it. | -| After a compaction, messages from before it have no checkpoint ("No file checkpoint found"), and a cut before it is refused ("No message found with message.uuid") before any model call | Stand-in API run. The refusal is recognised and the turn falls back to a fresh session. | -| `forkSession` copies no file history | The SDK's own documentation of `forkSession()`. Edits therefore never fork. | - -**Git.** For a project with a repository, the restore preview runs `git status` in the project's checkout to mark files with uncommitted changes. A restore never runs git: it rewrites file contents only, so it cannot remove a commit. A commit the agent made after the checkpoint stays in history (and on the remote if it was pushed), and the restored files then show as uncommitted changes. - ## Business rules +### The conversation list, search and export + +- **Archive is the default way to tidy the list; delete is the exception.** Keeping a + conversation costs nothing on a single-owner server, and delete cannot be undone. +- **A conversation is in exactly one place**: pinned, in the normal list, or archived. + Pinning an archived conversation unarchives it; archiving a pinned one unpins it. +- **Pinning and archiving never reorder the list.** The list is ordered by last activity, and + putting a conversation away is not activity. Unarchiving returns it to where it was. The + one exception is the Pinned group, which is ordered by when each conversation was pinned. + Choosing **Sort by Name** or **Project** applies inside the Pinned group too. +- **The home page's "Recent chats"** (shown on a phone, where the sidebar is hidden) are the + five most recently active conversations, pinned or not. Pins do not push newer + conversations off that short list. +- **Only the owner's own message brings an archived conversation back.** Automations and + monitors that post into a conversation (a scheduled report, for example) do not — a chat + archived on purpose should not reappear every time a job writes to it. +- **The normal list** shows every pinned conversation plus the 50 most recently active + others; the archive shows up to 200, most recently archived first. Search reaches + everything. +- **Other open tabs follow along**: pinning, archiving, deleting or a new chat elsewhere + refreshes the sidebar within a couple of seconds. A rename shows in other tabs the next + time that conversation has activity or the page is reloaded. +- **Deleting stops a running turn first.** Otherwise the agent could carry on using tools + for a conversation that no longer exists, with nothing left to stop it: the CLI session, + the commands it had put in the background (a dev server, a long build), and the subagents + it had delegated to all end with the turn. A delete waits at most ten seconds for the + stopped turn to finish, then goes ahead regardless. A turn run by an automation in the + background jobs worker cannot be reached this way (the same is true of Stop); it is short, + and its writes simply fail once the conversation is gone. An archived conversation is + deleted the same way. +- **What delete removes and what it keeps.** Deleting a conversation removes its messages, + its run history (the turns, their events, approvals and evaluations) and its search index. + The memories mined from it stay in the memory palace but lose their link back to the + conversation. Cost records stay, so spending totals do not change; they lose their link to + the run. The SDK's own transcript and file backups on disk are not deleted yet (see the + follow-up at the end). +- **Search is kept up to date in the background.** A message is indexed right after it is + saved or edited; indexing never delays or fails a message. Anything missed — history from + before search existed, or a message whose indexing failed — is indexed when the server + next starts. When the rules for what is indexed change, the server rebuilds the index the + same way. A message deleted while this catch-up is running is simply skipped; it does not + stop the rest from being indexed. Replies an edit or regenerate drops take their search + entries with them. +- **The search extract is shown as plain text.** Highlighting is added by the app itself, so + nothing in a message (for example, text that looks like HTML) can change how the page + behaves. +- **Export files are never cached** by the browser or anything in between, and are always + offered as a download rather than opened in the page. + +### Editing, regenerating and restoring files + **What the model sees after an edit or regenerate.** The page no longer sends the word "regenerate" as a prompt; the server answers the edited or regenerated message itself, using its stored text and attachments. How the SDK session is started: | Situation | What happens | @@ -128,16 +293,50 @@ Before this change both sent an ordinary message asking the model for a summary. - Compacting a conversation removes the backups for messages before the compaction. - A restore overwrites the files as they are now. Git can show you which files have uncommitted changes, but not who made them. +## Integrations + +- **PostgreSQL full-text search** (English) powers search: the index table holds the + searchable text and a pre-parsed form of it, with an index that makes lookups fast. +- Export reads the conversation as AgentStudio stored it — the same record the chat screen + shows — not the Claude Agent SDK's own session files. + +**Claude Agent SDK** (`@anthropic-ai/claude-agent-sdk` 0.3.278). What each piece of editing, regenerating and restoring relies on, and how it was verified rather than assumed: + +| Behaviour | How it was checked | +| --- | --- | +| A message sent with our own id keeps that id in the transcript, and file checkpoints are keyed by it | Ran the bundled CLI against a stand-in Messages API: the transcript's user entries and `file-history-snapshot` entries carried exactly the ids we sent. | +| `enableFileCheckpointing` backs up files the Write tool changes, and `rewindFiles(id, { dryRun: true })` lists them (absolute paths) with line counts, without touching them | Same run: a file created in turn 2 was listed by a dry run to turn 2, and both turns' files by a dry run to turn 1. | +| `resume` + `resumeSessionAt` keeps the same session id and drops everything after the cut from what the model is sent | Same run: the model received turn 1 and the edited text, and nothing from turn 2. | +| After such a cut, the dropped message's checkpoint is gone and the kept ones still work | Same run. This is why files are restored *before* the new reply, never after. | +| A session with no prompt answers `rewindFiles` from the history it loads at start-up, with no model call | Automated: `tests/engine.rewind.spec.ts` runs the real CLI against a synthetic session on disk. | +| `/compact` sent as a plain-text message runs the SDK's compaction; the summary it writes after the boundary is a valid place to cut an edit | Stand-in API run: a `compact_boundary` came back, the next turn sent only the summary, and an edit cut after the summary kept it. | +| After a compaction, messages from before it have no checkpoint ("No file checkpoint found"), and a cut before it is refused ("No message found with message.uuid") before any model call | Stand-in API run. The refusal is recognised and the turn falls back to a fresh session. | +| `forkSession` copies no file history | The SDK's own documentation of `forkSession()`. Edits therefore never fork. | +| An interrupt with no `perTaskStopAffordance` declared also kills the session's background tasks | The SDK's own documentation (`sdk.d.ts`). This is how deleting a conversation stops the commands its turn left running. | + +**Git.** For a project with a repository, the restore preview runs `git status` in the project's checkout to mark files with uncommitted changes. A restore never runs git: it rewrites file contents only, so it cannot remove a commit. A commit the agent made after the checkpoint stays in history (and on the remote if it was pushed), and the restored files then show as uncommitted changes. + ## Where it lives -| Piece | File | +| Piece | Location | | --- | --- | +| Pin, archive and unarchive-on-reply rules | `src/lib/chat/conversation-lifecycle.server.ts` | +| Delete, stopping a running turn first | `src/lib/chat/conversation-delete.server.ts` | +| The normal and archived lists | `src/lib/chat/conversation-list.server.ts` | +| The sidebar's order and grouping (pinned by pin time, archive by archive time) | `src/lib/chat/conversation-order.ts` | +| What a message is indexed by | `src/lib/chat/message-search-text.ts` | +| Turning a typed search into a query; highlight markers | `src/lib/chat/conversation-search.ts` | +| The index writer, boot backfill and search | `src/lib/chat/message-search.server.ts`, `message-search-sql.ts` | +| Export formats | `src/lib/chat/conversation-export.ts` | +| Export download | `GET /chat/[id]/export?format=md` or `?format=json` | +| Sidebar menu | `src/lib/chat-console/ConversationRowMenu.svelte` | | Turn planning: text, attachments and how the session starts | `src/lib/chat/turn-plan.ts`, `src/lib/chat/turn-plan.server.ts` | | The prompt's own id, the transcript tail, the refused-cut fallback | `src/lib/engine/turn-input.ts` | -| Edit and regenerate on the rows, and the pending-cut mark | `src/lib/chat/message-branch.server.ts` | +| Edit and regenerate on the rows, the pending-cut mark, and re-indexing an edited message | `src/lib/chat/message-branch.server.ts` | | The `/compact` prompt and the model-switch notice | `src/lib/chat/compact-command.ts` | | Restore preview and restore, with the guards | `src/lib/chat/rewind.server.ts`, `src/lib/chat/rewind-plan.ts`, `src/lib/chat/rewind-preview.ts` | | The short-lived SDK session a restore runs in | `src/lib/engine/rewind.server.ts` | -| The dialog | `src/lib/chat/RewindPreviewDialog.svelte`, `src/lib/chat/rewind-dialog.svelte.ts` | +| The restore dialog | `src/lib/chat/RewindPreviewDialog.svelte`, `src/lib/chat/rewind-dialog.svelte.ts` | +| Data | `conversations.pinned_at`, `conversations.archived_at`, and the `message_search` table (migration `0079`); the transcript join lives on `messages.metadata` | Follow-up, not done here: deleting a conversation does not yet delete its SDK transcript or file backups. diff --git a/docs/chat/spec.md b/docs/chat/spec.md index 3783ecfd..99f14a39 100644 --- a/docs/chat/spec.md +++ b/docs/chat/spec.md @@ -331,7 +331,7 @@ The agent can start a command in the background, such as a dev server, a watcher **Reloading or reconnecting mid-turn.** A page that is reloaded while the turn runs, or that reconnects after a dropped connection (a phone tab brought back to the front, a network blip), gets only what the server saved. So the server also saves the command's new output every five seconds or so while it keeps changing. A reloaded card catches up to what the command had printed a few seconds earlier, then keeps updating every few seconds instead of every second, and its **live** badge stays true. When the command ends, the card shows the final output that was saved with the reply. If some output never reached the page at all, the card says earlier output is missing rather than passing a fragment off as the whole thing. -**Deleting a conversation stops it first.** If a turn is still running when a conversation is deleted, that turn is stopped, and every background command it started goes with it. Then the conversation is deleted. Before, the conversation disappeared but its turn and commands kept running, with nothing left in the app that could stop them. +**Deleting a conversation stops it first.** If a turn is still running when a conversation is deleted, that turn is stopped, and every background command it started goes with it. The delete waits up to ten seconds for the stopped turn to save what it had done, then the conversation is deleted (see [chat.md](chat.md#delete-a-conversation)). Before, the conversation disappeared but its turn and commands kept running, with nothing left in the app that could stop them. Rules: diff --git a/docs/structure/claude-desktop-parity.md b/docs/structure/claude-desktop-parity.md index 626884c5..8d81c8ee 100644 --- a/docs/structure/claude-desktop-parity.md +++ b/docs/structure/claude-desktop-parity.md @@ -86,10 +86,10 @@ This is the Cowork comparison, and it is the one I got wrong in the first draft: | Artifacts / side-panel documents | **n/a** | removed in #13 — files on disk and git instead | versioned, publishable, in-place draft editing | | Preview a file or a website in a side panel | **even** (#29, #14) | right rail, folded to a strip until something is opened (remembered per user): Preview tab renders markdown, highlighted code, images, PDFs, directory listing, sandboxed URL iframe, selection persists per conversation; Files tab lists every file the agent changed in the chat with +/- counts and opens it in Preview. Run activity is on `/runs/`, linked from each reply | artifact + document preview | | Inline charts and visualizations | **absent** | markdown only | interactive charts, diagrams, Claude Design | -| Conversation rename | **even** | `updateConversationMeta` | same | -| Pin / archive / folders | **absent** (#18) | delete only | full | -| Search across conversations | **far behind** (#18) | client-side filter over loaded rows | server-side across all history | -| Export a conversation | **absent** (#18) | — | export | +| Conversation rename | **even** (#18) | Rename in each sidebar row's menu. Until #18 `updateConversationMeta` existed with no UI calling it, so this row's earlier "even" was wrong | same | +| Pin / archive / folders | **behind** (#18) | pin to a Pinned group on top; archive (the default way to tidy, one click on the row) hides a chat and keeps everything, with an Archived view to restore it; replying in an archived chat brings it back; delete is last in the menu, behind a confirmation. No folders | full | +| Search across conversations | **even** (#18) | server-side full-text search over all history: message text, attachment names and every tool call's paths, commands, short arguments and printed links (a delegated subagent's calls included), so a turn is found by the file it edited or the PR it opened; ranked, with highlighted extracts, prefix matching while typing, archived chats on request | server-side across all history | +| Export a conversation | **even** (#18) | Markdown transcript (diffs, commands, checklists; long output shortened) or complete JSON with every block, as a download from the row menu | export | | Temporary / incognito chat | **n/a** | delete covers it here | incognito | | Usage digest | **behind** (#38) | `/activity` usage strip over 24h / 7d / 30d: runs and failure rate, tokens first with metered dollars, automations, most-used tools, review inbox, budget headroom, and anomaly flags (spend spike, newly failing automation, monitor that never fired). An opt-in weekly digest sends the same numbers to the inbox or chat, rendered by code with no model call. Numbers only, no written narrative | smart reports, monthly recap | | Image generation | **win** | `image_generate` | — | @@ -141,6 +141,6 @@ Ordered for how this box is used — NAS host, repeating jobs, files rarely open **Wave 4 — the long tail.** -[#17](https://github.com/derekhearst/AgentStudio/issues/17) external MCP · [#18](https://github.com/derekhearst/AgentStudio/issues/18) conversation search, pin, archive, export · [#37](https://github.com/derekhearst/AgentStudio/issues/37) memory management · [#38](https://github.com/derekhearst/AgentStudio/issues/38) usage digest · [#22](https://github.com/derekhearst/AgentStudio/issues/22) slash commands and `@`-mentions (`@` and an app-actions `/` palette landed; SDK commands to follow) · [#23](https://github.com/derekhearst/AgentStudio/issues/23) project instructions · ~~[#27](https://github.com/derekhearst/AgentStudio/issues/27) the dead TTS endpoint~~ (finished instead: read-aloud) +[#17](https://github.com/derekhearst/AgentStudio/issues/17) external MCP · [#18](https://github.com/derekhearst/AgentStudio/issues/18) conversation search, pin, archive, export (landed) · [#37](https://github.com/derekhearst/AgentStudio/issues/37) memory management · [#38](https://github.com/derekhearst/AgentStudio/issues/38) usage digest · [#22](https://github.com/derekhearst/AgentStudio/issues/22) slash commands and `@`-mentions (`@` and an app-actions `/` palette landed; SDK commands to follow) · [#23](https://github.com/derekhearst/AgentStudio/issues/23) project instructions · ~~[#27](https://github.com/derekhearst/AgentStudio/issues/27) the dead TTS endpoint~~ (finished instead: read-aloud) **Deferred on purpose:** artifacts (#13, removed), incognito chat (delete covers it), remote control (the web UI is reachable anywhere), plugin marketplace and connector governance (no second user), computer use (large, and the browser tools cover the real cases), inline charts and Claude Design (worth revisiting only if the reports get visual). diff --git a/docs/structure/parity-reaudit-2026-09-22.md b/docs/structure/parity-reaudit-2026-09-22.md index bd3834ae..d76a4eb2 100644 --- a/docs/structure/parity-reaudit-2026-09-22.md +++ b/docs/structure/parity-reaudit-2026-09-22.md @@ -254,7 +254,7 @@ plan), **fold** (belongs inside another issue), **delete** (close it). | #32 | Multi-agent orchestration | **rebuild** — shipped | SDK `agents` + the `Agent` tool, children in the foreground and in parallel, gated by a PreToolUse hook rather than `canUseTool` | | #5 | Port subagents to SDK subagents | **as filed** — shipped | keystone; `Options.agents` + `Task`, `run_subagent` retired | | #4 | Native AskUserQuestion | **as filed** — shipped | the SDK's tool answered through `canUseTool`; HTML previews, multi-select, Other, timeout `never` | -| #18 | Conversation pin/archive/search/export | **as filed**, trimmed | all four are cheap; make archive the default action, not delete | +| #18 | Conversation pin/archive/search/export | **as filed**, trimmed — shipped | all four are cheap; make archive the default action, not delete | | #22 | Slash commands and `@`-mentions | **split** — `@` shipped, `/` shipped over app actions | `@` lists the next turn's workspace; the `/` palette covers the app's own buttons, with SDK commands still to add | | #38 | Usage digest | **rebuild** | fix the ledger first, then ship the header strip; the digest agent is the last 20% | | #14 | Rethink the right sidebar | **rebuild** → **shipped** | #29 already fixed the "blank by default" complaint; what is left is deleting two tabs. Shipped as Preview + a real Files tab, collapsed by default; see below | @@ -401,7 +401,8 @@ that nothing pretends otherwise any more: refuses the Read the CLI suggests), and to `tee` into the workspace instead. - **Delete.** Deleting a conversation interrupts its live run before the row goes. With no `perTaskStopAffordance` declared, the CLI's interrupt also kills its background tasks - (`sdk.d.ts`). + (`sdk.d.ts`). It shares one delete path with #18, which checks ownership first and waits + up to ten seconds for the stopped turn to wind down. Not done: - A command outliving its turn (option B: one `query()` per conversation, fed turn by turn, @@ -683,6 +684,25 @@ All four parts are cheap and worth doing. Two opinions: Export to Markdown is ~50 lines and does not need a design. +**Shipped.** Both opinions stand. `conversations.pinned_at` / `archived_at` (migration +`0079`); archive is the one-click action on a sidebar row and first in its menu, delete is +last and behind a confirmation that suggests archiving. A message the user sends unarchives; +an automation posting into the chat does not. Search indexes tool blocks as argued above, +in a `message_search` side table (a generated tsvector with a GIN index) so the chat page's +message payload is untouched: each tool call contributes its name, paths, commands, short +arguments and the links in its output, and every path is also spelled out as segments, +because Postgres parses `src/lib/engine/options.server.ts` as one token and a search for +`options` would otherwise miss it. Raw output and file bodies are not indexed. A boot +backfill indexes history and rebuilds the index when the rules change. One correction to the +opinion above: deleting a conversation does not take its cost ledger with it — the rows stay +and lose their run link. Deleting stops the conversation's live turn first — and with it the +background commands (#35) and delegated children (#32) that end with the turn — and waits up +to ten seconds for it to wind down; one module does this for both issues. An edited message +(#24's `editUserMessage`) is re-indexed by its new text, and a delegated child's own calls +(#32's card transcript, which replaced the delegation's tool block) are indexed and exported +like the parent's. Export is Markdown plus a complete +JSON. Details in [docs/chat/chat.md](../chat/chat.md). + ### #22 — composer Split it. `@`-mentions are unambiguous value and the issue's plan is fine. @@ -846,7 +866,7 @@ Claude and a gateway model, and each non-Claude model's multi-step tool use. See (#8) and gets worktrees for free. 5. **#23 via `settingSources`**, then the `/` half of #22 on top of it. 6. **#17**, HTTP transport first. -7. The cheap independents whenever: ~~**#27 delete**~~ (finished instead), ~~**#4**~~ (shipped), **#18**, ~~**#14**~~ (shipped). +7. The cheap independents whenever: ~~**#27 delete**~~ (finished instead), ~~**#4**~~ (shipped), ~~**#18**~~ (shipped), ~~**#14**~~ (shipped). Rough shape of it: items 1–3 are maybe a week of work that makes five issues small, and four of the open issues (#27 plus the obsolete halves of #24, #32 and #35) should be closed or diff --git a/drizzle/0079_conversation_lifecycle.sql b/drizzle/0079_conversation_lifecycle.sql new file mode 100644 index 00000000..a962633f --- /dev/null +++ b/drizzle/0079_conversation_lifecycle.sql @@ -0,0 +1,15 @@ +CREATE TABLE "message_search" ( + "message_id" uuid PRIMARY KEY NOT NULL, + "conversation_id" uuid NOT NULL, + "body" text NOT NULL, + "builder_version" integer NOT NULL, + "tsv" "tsvector" GENERATED ALWAYS AS (to_tsvector('english', "message_search"."body")) STORED, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "conversations" ADD COLUMN "pinned_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "conversations" ADD COLUMN "archived_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "message_search" ADD CONSTRAINT "message_search_message_id_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."messages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "message_search" ADD CONSTRAINT "message_search_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "message_search_tsv_idx" ON "message_search" USING gin ("tsv");--> statement-breakpoint +CREATE INDEX "message_search_conversation_idx" ON "message_search" USING btree ("conversation_id"); \ No newline at end of file diff --git a/drizzle/meta/0079_snapshot.json b/drizzle/meta/0079_snapshot.json new file mode 100644 index 00000000..45395b53 --- /dev/null +++ b/drizzle/meta/0079_snapshot.json @@ -0,0 +1,8050 @@ +{ + "id": "cc416eb1-fc0e-47e6-8783-226f5a105703", + "prevId": "f388e03c-3a41-461b-88d7-7e823902f0da", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_events": { + "name": "activity_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "activity_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-sonnet-5'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "agent_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "kind": { + "name": "kind", + "type": "agent_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'worker'" + }, + "parent_agent_id": { + "name": "parent_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "identity_skill_id": { + "name": "identity_skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "builtin_key": { + "name": "builtin_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "anchor_prompt": { + "name": "anchor_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_builtin_key_uidx": { + "name": "agents_builtin_key_uidx", + "columns": [ + { + "expression": "builtin_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agents\".\"builtin_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_sessions_user_id_users_id_fk": { + "name": "auth_sessions_user_id_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_sessions_token_hash_unique": { + "name": "auth_sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_singleton": { + "name": "users_singleton", + "columns": [ + { + "expression": "((true))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_runs": { + "name": "automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'chat_followup'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_run_id": { + "name": "chat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "research_id": { + "name": "research_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(18, 12)", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output_excerpt": { + "name": "output_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_runs_automation_idx": { + "name": "automation_runs_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_status_idx": { + "name": "automation_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_started_idx": { + "name": "automation_runs_started_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_user_id_users_id_fk": { + "name": "automation_runs_user_id_users_id_fk", + "tableFrom": "automation_runs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automations": { + "name": "automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'America/Boise'" + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "conversation_mode": { + "name": "conversation_mode", + "type": "automation_conversation_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new_each_run'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "automation_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'chat_followup'" + }, + "output_target": { + "name": "output_target", + "type": "automation_output_target", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'chat_session'" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "disabled_reason": { + "name": "disabled_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automations_mode_idx": { + "name": "automations_mode_idx", + "columns": [ + { + "expression": "mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automations_repository_idx": { + "name": "automations_repository_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automations_user_id_users_id_fk": { + "name": "automations_user_id_users_id_fk", + "tableFrom": "automations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automations_agent_id_agents_id_fk": { + "name": "automations_agent_id_agents_id_fk", + "tableFrom": "automations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "automations_conversation_id_conversations_id_fk": { + "name": "automations_conversation_id_conversations_id_fk", + "tableFrom": "automations", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_rail_preview": { + "name": "chat_rail_preview", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tab": { + "name": "tab", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Preview'" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_rail_preview_user_idx": { + "name": "chat_rail_preview_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_rail_preview_conversation_id_conversations_id_fk": { + "name": "chat_rail_preview_conversation_id_conversations_id_fk", + "tableFrom": "chat_rail_preview", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_rail_preview_user_id_users_id_fk": { + "name": "chat_rail_preview_user_id_users_id_fk", + "tableFrom": "chat_rail_preview", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_rail_preview_conversation_id_unique": { + "name": "chat_rail_preview_conversation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "conversation_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_workbench_preferences": { + "name": "chat_workbench_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "default_agent_id": { + "name": "default_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "show_right_panel": { + "name": "show_right_panel", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "panel_layout": { + "name": "panel_layout", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_workbench_preferences_user_id_users_id_fk": { + "name": "chat_workbench_preferences_user_id_users_id_fk", + "tableFrom": "chat_workbench_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_workbench_preferences_default_agent_id_agents_id_fk": { + "name": "chat_workbench_preferences_default_agent_id_agents_id_fk", + "tableFrom": "chat_workbench_preferences", + "tableTo": "agents", + "columnsFrom": [ + "default_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_workbench_preferences_user_id_unique": { + "name": "chat_workbench_preferences_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.context_slot_configs": { + "name": "context_slot_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slot_name": { + "name": "slot_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_budget": { + "name": "token_budget", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "context_slot_configs_user_idx": { + "name": "context_slot_configs_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "context_slot_configs_agent_idx": { + "name": "context_slot_configs_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "context_slot_configs_user_id_users_id_fk": { + "name": "context_slot_configs_user_id_users_id_fk", + "tableFrom": "context_slot_configs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "context_slot_configs_agent_id_agents_id_fk": { + "name": "context_slot_configs_agent_id_agents_id_fk", + "tableFrom": "context_slot_configs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "context_slot_configs_user_agent_slot_unique": { + "name": "context_slot_configs_user_agent_slot_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "agent_id", + "slot_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_alerts": { + "name": "budget_alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "budget_limit_id": { + "name": "budget_limit_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "budget_trigger_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "spend_at_trigger": { + "name": "spend_at_trigger", + "type": "numeric(18, 6)", + "primaryKey": false, + "notNull": true + }, + "limit_usd": { + "name": "limit_usd", + "type": "numeric(18, 6)", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "budget_period", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_alerts_user_idx": { + "name": "budget_alerts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_alerts_limit_idx": { + "name": "budget_alerts_limit_idx", + "columns": [ + { + "expression": "budget_limit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_alerts_created_idx": { + "name": "budget_alerts_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_alerts_budget_limit_id_budget_limits_id_fk": { + "name": "budget_alerts_budget_limit_id_budget_limits_id_fk", + "tableFrom": "budget_alerts", + "tableTo": "budget_limits", + "columnsFrom": [ + "budget_limit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "budget_alerts_user_id_users_id_fk": { + "name": "budget_alerts_user_id_users_id_fk", + "tableFrom": "budget_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "budget_alerts_run_id_chat_runs_id_fk": { + "name": "budget_alerts_run_id_chat_runs_id_fk", + "tableFrom": "budget_alerts", + "tableTo": "chat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_limits": { + "name": "budget_limits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "budget_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "period": { + "name": "period", + "type": "budget_period", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "limit_usd": { + "name": "limit_usd", + "type": "numeric(18, 6)", + "primaryKey": false, + "notNull": true + }, + "warn_usd": { + "name": "warn_usd", + "type": "numeric(18, 6)", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "budget_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'block'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_limits_user_idx": { + "name": "budget_limits_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_limits_scope_idx": { + "name": "budget_limits_scope_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_limits_user_id_users_id_fk": { + "name": "budget_limits_user_id_users_id_fk", + "tableFrom": "budget_limits", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.llm_usage": { + "name": "llm_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens_cache_write": { + "name": "tokens_cache_write", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens_cache_read": { + "name": "tokens_cache_read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost": { + "name": "cost", + "type": "numeric(18, 12)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "llm_usage_user_idx": { + "name": "llm_usage_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "llm_usage_run_idx": { + "name": "llm_usage_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "llm_usage_agent_idx": { + "name": "llm_usage_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "llm_usage_created_idx": { + "name": "llm_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "llm_usage_user_id_users_id_fk": { + "name": "llm_usage_user_id_users_id_fk", + "tableFrom": "llm_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "llm_usage_run_id_chat_runs_id_fk": { + "name": "llm_usage_run_id_chat_runs_id_fk", + "tableFrom": "llm_usage", + "tableTo": "chat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "llm_usage_agent_id_agents_id_fk": { + "name": "llm_usage_agent_id_agents_id_fk", + "tableFrom": "llm_usage", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_usage": { + "name": "tool_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit_type": { + "name": "unit_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "units": { + "name": "units", + "type": "numeric(18, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "cost": { + "name": "cost", + "type": "numeric(18, 12)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tool_usage_user_idx": { + "name": "tool_usage_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_usage_run_idx": { + "name": "tool_usage_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_usage_agent_idx": { + "name": "tool_usage_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_usage_tool_idx": { + "name": "tool_usage_tool_idx", + "columns": [ + { + "expression": "tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_usage_created_idx": { + "name": "tool_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_usage_user_id_users_id_fk": { + "name": "tool_usage_user_id_users_id_fk", + "tableFrom": "tool_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_usage_run_id_chat_runs_id_fk": { + "name": "tool_usage_run_id_chat_runs_id_fk", + "tableFrom": "tool_usage", + "tableTo": "chat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_usage_agent_id_agents_id_fk": { + "name": "tool_usage_agent_id_agents_id_fk", + "tableFrom": "tool_usage", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.run_evaluations": { + "name": "run_evaluations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "evaluator_run_id": { + "name": "evaluator_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "evaluator_agent_id": { + "name": "evaluator_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verdict": { + "name": "verdict", + "type": "evaluation_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "findings": { + "name": "findings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(12, 4)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "run_evaluations_run_idx": { + "name": "run_evaluations_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "run_evaluations_evaluator_agent_idx": { + "name": "run_evaluations_evaluator_agent_idx", + "columns": [ + { + "expression": "evaluator_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "run_evaluations_verdict_idx": { + "name": "run_evaluations_verdict_idx", + "columns": [ + { + "expression": "verdict", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "run_evaluations_created_idx": { + "name": "run_evaluations_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_evaluations_run_id_chat_runs_id_fk": { + "name": "run_evaluations_run_id_chat_runs_id_fk", + "tableFrom": "run_evaluations", + "tableTo": "chat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "run_evaluations_evaluator_run_id_chat_runs_id_fk": { + "name": "run_evaluations_evaluator_run_id_chat_runs_id_fk", + "tableFrom": "run_evaluations", + "tableTo": "chat_runs", + "columnsFrom": [ + "evaluator_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "run_evaluations_evaluator_agent_id_agents_id_fk": { + "name": "run_evaluations_evaluator_agent_id_agents_id_fk", + "tableFrom": "run_evaluations", + "tableTo": "agents", + "columnsFrom": [ + "evaluator_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "audit_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before_state": { + "name": "before_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_state": { + "name": "after_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_actor_idx": { + "name": "audit_events_actor_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_action_idx": { + "name": "audit_events_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_idx": { + "name": "audit_events_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_created_idx": { + "name": "audit_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_events_actor_user_id_users_id_fk": { + "name": "audit_events_actor_user_id_users_id_fk", + "tableFrom": "audit_events", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hook_invocations": { + "name": "hook_invocations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hook_kind": { + "name": "hook_kind", + "type": "hook_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "hook_ref": { + "name": "hook_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hook_invocations_run_idx": { + "name": "hook_invocations_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hook_invocations_event_idx": { + "name": "hook_invocations_event_idx", + "columns": [ + { + "expression": "event", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hook_invocations_success_idx": { + "name": "hook_invocations_success_idx", + "columns": [ + { + "expression": "success", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hook_invocations_created_idx": { + "name": "hook_invocations_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hook_invocations_run_id_chat_runs_id_fk": { + "name": "hook_invocations_run_id_chat_runs_id_fk", + "tableFrom": "hook_invocations", + "tableTo": "chat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.images": { + "name": "images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(12, 4)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "images_user_idx": { + "name": "images_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "images_conversation_idx": { + "name": "images_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "images_created_idx": { + "name": "images_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "images_user_id_users_id_fk": { + "name": "images_user_id_users_id_fk", + "tableFrom": "images", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_leases": { + "name": "job_leases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "worker_id": { + "name": "worker_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_leases_job_idx": { + "name": "job_leases_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_leases_expires_idx": { + "name": "job_leases_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_leases_job_id_jobs_id_fk": { + "name": "job_leases_job_id_jobs_id_fk", + "tableFrom": "job_leases", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_policies": { + "name": "job_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "backoff_ms": { + "name": "backoff_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5000 + }, + "concurrency_key": { + "name": "concurrency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "concurrency_limit": { + "name": "concurrency_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "timeout_ms": { + "name": "timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60000 + }, + "cancel_behavior": { + "name": "cancel_behavior", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'best_effort'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "job_policies_job_type_unique": { + "name": "job_policies_job_type_unique", + "nullsNotDistinct": false, + "columns": [ + "job_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "queue": { + "name": "queue", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "jobs_claim_idx": { + "name": "jobs_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_queue_idx": { + "name": "jobs_queue_idx", + "columns": [ + { + "expression": "queue", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_type_idx": { + "name": "jobs_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_run_idx": { + "name": "jobs_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_user_idx": { + "name": "jobs_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_type_dedupe_active_uidx": { + "name": "jobs_type_dedupe_active_uidx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"jobs\".\"status\" in ('pending', 'leased', 'running', 'retry_wait')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_type_dedupe_idx": { + "name": "jobs_type_dedupe_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_user_id_users_id_fk": { + "name": "jobs_user_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_closets": { + "name": "memory_closets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "topic": { + "name": "topic", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_closets_room_idx": { + "name": "memory_closets_room_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_closets_room_id_memory_rooms_id_fk": { + "name": "memory_closets_room_id_memory_rooms_id_fk", + "tableFrom": "memory_closets", + "tableTo": "memory_rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "memory_closets_room_topic_unique": { + "name": "memory_closets_room_topic_unique", + "nullsNotDistinct": false, + "columns": [ + "room_id", + "topic" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_drawers": { + "name": "memory_drawers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "closet_id": { + "name": "closet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "memory_drawer_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'note'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "aaak": { + "name": "aaak", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_message_id": { + "name": "source_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "never_recall": { + "name": "never_recall", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "edited_at": { + "name": "edited_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_drawers_closet_idx": { + "name": "memory_drawers_closet_idx", + "columns": [ + { + "expression": "closet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_drawers_user_occurred_idx": { + "name": "memory_drawers_user_occurred_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_drawers_user_pinned_idx": { + "name": "memory_drawers_user_pinned_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pinned", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_drawers_source_message_idx": { + "name": "memory_drawers_source_message_idx", + "columns": [ + { + "expression": "source_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_drawers_embedding_hnsw_idx": { + "name": "memory_drawers_embedding_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + }, + "memory_drawers_content_tsv_idx": { + "name": "memory_drawers_content_tsv_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"content\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "memory_drawers_closet_id_memory_closets_id_fk": { + "name": "memory_drawers_closet_id_memory_closets_id_fk", + "tableFrom": "memory_drawers", + "tableTo": "memory_closets", + "columnsFrom": [ + "closet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_drawers_user_id_users_id_fk": { + "name": "memory_drawers_user_id_users_id_fk", + "tableFrom": "memory_drawers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_drawers_source_message_id_messages_id_fk": { + "name": "memory_drawers_source_message_id_messages_id_fk", + "tableFrom": "memory_drawers", + "tableTo": "messages", + "columnsFrom": [ + "source_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_exclusion_rules": { + "name": "memory_exclusion_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "memory_exclusion_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "builtin": { + "name": "builtin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hit_count": { + "name": "hit_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_hit_at": { + "name": "last_hit_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_exclusion_rules_user_idx": { + "name": "memory_exclusion_rules_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_exclusion_rules_user_id_users_id_fk": { + "name": "memory_exclusion_rules_user_id_users_id_fk", + "tableFrom": "memory_exclusion_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "memory_exclusion_rules_user_name_unique": { + "name": "memory_exclusion_rules_user_name_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_kg_entities": { + "name": "memory_kg_entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'thing'" + }, + "attributes": { + "name": "attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_kg_entities_user_idx": { + "name": "memory_kg_entities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_kg_entities_user_id_users_id_fk": { + "name": "memory_kg_entities_user_id_users_id_fk", + "tableFrom": "memory_kg_entities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "memory_kg_entities_user_name_type_unique": { + "name": "memory_kg_entities_user_name_type_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name", + "type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_kg_relations": { + "name": "memory_kg_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "from_entity_id": { + "name": "from_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "to_entity_id": { + "name": "to_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "relation": { + "name": "relation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "valid_from": { + "name": "valid_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "valid_to": { + "name": "valid_to", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source_drawer_id": { + "name": "source_drawer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_kg_relations_user_idx": { + "name": "memory_kg_relations_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_kg_relations_from_idx": { + "name": "memory_kg_relations_from_idx", + "columns": [ + { + "expression": "from_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "valid_from", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_kg_relations_to_idx": { + "name": "memory_kg_relations_to_idx", + "columns": [ + { + "expression": "to_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "valid_from", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_kg_relations_user_id_users_id_fk": { + "name": "memory_kg_relations_user_id_users_id_fk", + "tableFrom": "memory_kg_relations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_kg_relations_from_entity_id_memory_kg_entities_id_fk": { + "name": "memory_kg_relations_from_entity_id_memory_kg_entities_id_fk", + "tableFrom": "memory_kg_relations", + "tableTo": "memory_kg_entities", + "columnsFrom": [ + "from_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_kg_relations_to_entity_id_memory_kg_entities_id_fk": { + "name": "memory_kg_relations_to_entity_id_memory_kg_entities_id_fk", + "tableFrom": "memory_kg_relations", + "tableTo": "memory_kg_entities", + "columnsFrom": [ + "to_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_kg_relations_source_drawer_id_memory_drawers_id_fk": { + "name": "memory_kg_relations_source_drawer_id_memory_drawers_id_fk", + "tableFrom": "memory_kg_relations", + "tableTo": "memory_drawers", + "columnsFrom": [ + "source_drawer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_message_tombstones": { + "name": "memory_message_tombstones", + "schema": "", + "columns": { + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_message_tombstones_message_id_messages_id_fk": { + "name": "memory_message_tombstones_message_id_messages_id_fk", + "tableFrom": "memory_message_tombstones", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_message_tombstones_user_id_users_id_fk": { + "name": "memory_message_tombstones_user_id_users_id_fk", + "tableFrom": "memory_message_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_recall_events": { + "name": "memory_recall_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "drawer_id": { + "name": "drawer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "memory_recall_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'chat'" + }, + "rank": { + "name": "rank", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "semantic_score": { + "name": "semantic_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "keyword_score": { + "name": "keyword_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "temporal_score": { + "name": "temporal_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pinned_boost": { + "name": "pinned_boost", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "final_score": { + "name": "final_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "weights": { + "name": "weights", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_recall_events_drawer_idx": { + "name": "memory_recall_events_drawer_idx", + "columns": [ + { + "expression": "drawer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_recall_events_user_idx": { + "name": "memory_recall_events_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_recall_events_user_id_users_id_fk": { + "name": "memory_recall_events_user_id_users_id_fk", + "tableFrom": "memory_recall_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_recall_events_drawer_id_memory_drawers_id_fk": { + "name": "memory_recall_events_drawer_id_memory_drawers_id_fk", + "tableFrom": "memory_recall_events", + "tableTo": "memory_drawers", + "columnsFrom": [ + "drawer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_rooms": { + "name": "memory_rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wing_id": { + "name": "wing_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_rooms_wing_idx": { + "name": "memory_rooms_wing_idx", + "columns": [ + { + "expression": "wing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_rooms_occurred_idx": { + "name": "memory_rooms_occurred_idx", + "columns": [ + { + "expression": "wing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_rooms_conversation_idx": { + "name": "memory_rooms_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_rooms_wing_id_memory_wings_id_fk": { + "name": "memory_rooms_wing_id_memory_wings_id_fk", + "tableFrom": "memory_rooms", + "tableTo": "memory_wings", + "columnsFrom": [ + "wing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_rooms_conversation_id_conversations_id_fk": { + "name": "memory_rooms_conversation_id_conversations_id_fk", + "tableFrom": "memory_rooms", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_wings": { + "name": "memory_wings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "memory_wing_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'topic'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_wings_user_idx": { + "name": "memory_wings_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_wings_aliases_gin": { + "name": "memory_wings_aliases_gin", + "columns": [ + { + "expression": "aliases", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "memory_wings_user_id_users_id_fk": { + "name": "memory_wings_user_id_users_id_fk", + "tableFrom": "memory_wings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_wings_agent_id_agents_id_fk": { + "name": "memory_wings_agent_id_agents_id_fk", + "tableFrom": "memory_wings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "memory_wings_user_slug_unique": { + "name": "memory_wings_user_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.monitors": { + "name": "monitors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "monitor_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "condition_kind": { + "name": "condition_kind", + "type": "monitor_condition_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "condition": { + "name": "condition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "monitor_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "action_config": { + "name": "action_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 900 + }, + "deadline_at": { + "name": "deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "max_checks": { + "name": "max_checks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 200 + }, + "check_count": { + "name": "check_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_check_at": { + "name": "next_check_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_observation": { + "name": "last_observation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "condition_met": { + "name": "condition_met", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fire_count": { + "name": "fire_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_fire_result": { + "name": "last_fire_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "consecutive_errors": { + "name": "consecutive_errors", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "one_shot": { + "name": "one_shot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "monitors_due_idx": { + "name": "monitors_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_check_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monitors_user_idx": { + "name": "monitors_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monitors_deadline_idx": { + "name": "monitors_deadline_idx", + "columns": [ + { + "expression": "deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monitors_user_id_users_id_fk": { + "name": "monitors_user_id_users_id_fk", + "tableFrom": "monitors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitors_agent_id_agents_id_fk": { + "name": "monitors_agent_id_agents_id_fk", + "tableFrom": "monitors", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keys": { + "name": "keys", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "device_label": { + "name": "device_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_logs": { + "name": "app_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ts": { + "name": "ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "level": { + "name": "level", + "type": "log_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "app_logs_ts_idx": { + "name": "app_logs_ts_idx", + "columns": [ + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "app_logs_level_ts_idx": { + "name": "app_logs_level_ts_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "app_logs_source_ts_idx": { + "name": "app_logs_source_ts_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_logs_user_id_users_id_fk": { + "name": "app_logs_user_id_users_id_fk", + "tableFrom": "app_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operational_metrics": { + "name": "operational_metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dimension": { + "name": "dimension", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "value": { + "name": "value", + "type": "numeric(18, 6)", + "primaryKey": false, + "notNull": true + }, + "measured_at": { + "name": "measured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "operational_metrics_metric_measured_idx": { + "name": "operational_metrics_metric_measured_idx", + "columns": [ + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "measured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "operational_metrics_measured_idx": { + "name": "operational_metrics_measured_idx", + "columns": [ + { + "expression": "measured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_items": { + "name": "review_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "review_item_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_item_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "severity": { + "name": "severity", + "type": "review_item_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'warning'" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_to": { + "name": "assigned_to", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_items_type_status_idx": { + "name": "review_items_type_status_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_items_status_severity_idx": { + "name": "review_items_status_severity_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_items_assigned_idx": { + "name": "review_items_assigned_idx", + "columns": [ + { + "expression": "assigned_to", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_items_run_idx": { + "name": "review_items_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_items_job_idx": { + "name": "review_items_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_items_created_idx": { + "name": "review_items_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_items_assigned_to_users_id_fk": { + "name": "review_items_assigned_to_users_id_fk", + "tableFrom": "review_items", + "tableTo": "users", + "columnsFrom": [ + "assigned_to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_items_resolved_by_users_id_fk": { + "name": "review_items_resolved_by_users_id_fk", + "tableFrom": "review_items", + "tableTo": "users", + "columnsFrom": [ + "resolved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.run_traces": { + "name": "run_traces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trace": { + "name": "trace", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "run_trace_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "tool_call_count": { + "name": "tool_call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "round_count": { + "name": "round_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(12, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "run_traces_run_idx": { + "name": "run_traces_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "run_traces_status_idx": { + "name": "run_traces_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "run_traces_started_idx": { + "name": "run_traces_started_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "project_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'other'" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_kind": { + "name": "repo_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "repo_local_path": { + "name": "repo_local_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings_trusted": { + "name": "settings_trusted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_pulled_at": { + "name": "last_pulled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_imported_at": { + "name": "last_imported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_user_idx": { + "name": "projects_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_kind_idx": { + "name": "projects_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_user_id_users_id_fk": { + "name": "projects_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_user_slug_unique": { + "name": "projects_user_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.research": { + "name": "research", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "research_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "plan": { + "name": "plan", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "report": { + "name": "report", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(12, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "research_user_idx": { + "name": "research_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "research_status_idx": { + "name": "research_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "research_conversation_idx": { + "name": "research_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "research_job_idx": { + "name": "research_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "research_created_idx": { + "name": "research_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "research_user_id_users_id_fk": { + "name": "research_user_id_users_id_fk", + "tableFrom": "research", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.research_sources": { + "name": "research_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "research_id": { + "name": "research_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "extracted_text": { + "name": "extracted_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'html'" + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "cited_in_report": { + "name": "cited_in_report", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(12, 4)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "research_sources_research_idx": { + "name": "research_sources_research_idx", + "columns": [ + { + "expression": "research_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "research_sources_cited_idx": { + "name": "research_sources_cited_idx", + "columns": [ + { + "expression": "cited_in_report", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "research_sources_research_id_research_id_fk": { + "name": "research_sources_research_id_research_id_fk", + "tableFrom": "research_sources", + "tableTo": "research", + "columnsFrom": [ + "research_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.research_steps": { + "name": "research_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "research_id": { + "name": "research_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "research_step_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "sub_question": { + "name": "sub_question", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(12, 4)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "research_steps_research_idx": { + "name": "research_steps_research_idx", + "columns": [ + { + "expression": "research_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "research_steps_seq_idx": { + "name": "research_steps_seq_idx", + "columns": [ + { + "expression": "research_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "research_steps_kind_idx": { + "name": "research_steps_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "research_steps_research_id_research_id_fk": { + "name": "research_steps_research_id_research_id_fk", + "tableFrom": "research_steps", + "tableTo": "research", + "columnsFrom": [ + "research_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_runs": { + "name": "chat_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "chat_run_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "source": { + "name": "source", + "type": "chat_run_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'chat_stream'" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_delta": { + "name": "last_delta", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pending_approvals": { + "name": "pending_approvals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "pending_questions": { + "name": "pending_questions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "stream_blocks": { + "name": "stream_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "current_round": { + "name": "current_round", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_event_seq": { + "name": "next_event_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "eval_required": { + "name": "eval_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "eval_attempt": { + "name": "eval_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_runs_conversation_idx": { + "name": "chat_runs_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_runs_user_idx": { + "name": "chat_runs_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_runs_agent_idx": { + "name": "chat_runs_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_runs_state_idx": { + "name": "chat_runs_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_runs_updated_idx": { + "name": "chat_runs_updated_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_runs_conversation_id_conversations_id_fk": { + "name": "chat_runs_conversation_id_conversations_id_fk", + "tableFrom": "chat_runs", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_runs_user_id_users_id_fk": { + "name": "chat_runs_user_id_users_id_fk", + "tableFrom": "chat_runs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chat_runs_agent_id_agents_id_fk": { + "name": "chat_runs_agent_id_agents_id_fk", + "tableFrom": "chat_runs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.run_events": { + "name": "run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "run_events_run_seq_idx": { + "name": "run_events_run_seq_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_events_run_id_chat_runs_id_fk": { + "name": "run_events_run_id_chat_runs_id_fk", + "tableFrom": "run_events", + "tableTo": "chat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-sonnet-5'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric(18, 12)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sdk_session_id": { + "name": "sdk_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "conversation_permission_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "todo_list": { + "name": "todo_list", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_agent_id_agents_id_fk": { + "name": "conversations_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_search": { + "name": "message_search", + "schema": "", + "columns": { + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "builder_version": { + "name": "builder_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tsv": { + "name": "tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"message_search\".\"body\")", + "type": "stored" + } + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "message_search_tsv_idx": { + "name": "message_search_tsv_idx", + "columns": [ + { + "expression": "tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "message_search_conversation_idx": { + "name": "message_search_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_search_message_id_messages_id_fk": { + "name": "message_search_message_id_messages_id_fk", + "tableFrom": "message_search", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_search_conversation_id_conversations_id_fk": { + "name": "message_search_conversation_id_conversations_id_fk", + "tableFrom": "message_search", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "message_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tool_calls": { + "name": "tool_calls", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost": { + "name": "cost", + "type": "numeric(18, 12)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "ttft_ms": { + "name": "ttft_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_ms": { + "name": "total_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_per_sec": { + "name": "tokens_per_sec", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_settings": { + "name": "app_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-sonnet-5'" + }, + "transcription_model": { + "name": "transcription_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'google/gemini-2.5-flash'" + }, + "tts_model": { + "name": "tts_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hexgrad/kokoro-82m'" + }, + "tts_voice": { + "name": "tts_voice", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'af_heart'" + }, + "notification_prefs": { + "name": "notification_prefs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"taskCompleted\":true,\"needsInput\":true,\"agentErrors\":true}'::jsonb" + }, + "budget_config": { + "name": "budget_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"dailyLimit\":null,\"monthlyLimit\":null}'::jsonb" + }, + "context_config": { + "name": "context_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"reservedResponsePct\":30,\"autoCompactThresholdPct\":72}'::jsonb" + }, + "tool_config": { + "name": "tool_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"approvalRequiredTools\":[]}'::jsonb" + }, + "memory_config": { + "name": "memory_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"enabled\":true,\"topK\":5,\"useRerank\":false,\"rerankModel\":\"claude-haiku-4-5\",\"embeddingModel\":\"openai/text-embedding-3-small\",\"autoMine\":true}'::jsonb" + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'AgentStudio-night'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "app_settings_user_id_users_id_fk": { + "name": "app_settings_user_id_users_id_fk", + "tableFrom": "app_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_files": { + "name": "skill_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "skill_id": { + "name": "skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "skill_files_skill_id_skills_id_fk": { + "name": "skill_files_skill_id_skills_id_fk", + "tableFrom": "skill_files", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skill_files_skill_id_name_unique": { + "name": "skill_files_skill_id_name_unique", + "nullsNotDistinct": false, + "columns": [ + "skill_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_accessed": { + "name": "last_accessed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "description_embedding": { + "name": "description_embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "description_embedded_at": { + "name": "description_embedded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_file": { + "name": "source_file", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pull_request_checks": { + "name": "pull_request_checks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "check_name": { + "name": "check_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "pull_request_check_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "details_url": { + "name": "details_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_checks_pr_idx": { + "name": "pr_checks_pr_idx", + "columns": [ + { + "expression": "pull_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_checks_status_idx": { + "name": "pr_checks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pull_request_checks_pull_request_id_pull_requests_id_fk": { + "name": "pull_request_checks_pull_request_id_pull_requests_id_fk", + "tableFrom": "pull_request_checks", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pr_checks_pr_check_name_unique": { + "name": "pr_checks_pr_check_name_unique", + "nullsNotDistinct": false, + "columns": [ + "pull_request_id", + "check_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pull_requests": { + "name": "pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_pr_number": { + "name": "provider_pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "pull_request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_url": { + "name": "provider_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pull_requests_repo_idx": { + "name": "pull_requests_repo_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_requests_status_idx": { + "name": "pull_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_requests_run_idx": { + "name": "pull_requests_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pull_requests_repository_id_repositories_id_fk": { + "name": "pull_requests_repository_id_repositories_id_fk", + "tableFrom": "pull_requests", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pull_requests_created_by_users_id_fk": { + "name": "pull_requests_created_by_users_id_fk", + "tableFrom": "pull_requests", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pull_requests_repo_pr_number_unique": { + "name": "pull_requests_repo_pr_number_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id", + "provider_pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "source_control_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clone_url": { + "name": "clone_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositories_user_idx": { + "name": "repositories_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_project_idx": { + "name": "repositories_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_idx": { + "name": "repositories_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repositories_user_id_users_id_fk": { + "name": "repositories_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "repositories_user_owner_name_unique": { + "name": "repositories_user_owner_name_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "owner", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repository_branches": { + "name": "repository_branches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repo_branches_repo_idx": { + "name": "repo_branches_repo_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repository_branches_repository_id_repositories_id_fk": { + "name": "repository_branches_repository_id_repositories_id_fk", + "tableFrom": "repository_branches", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "repo_branches_repo_name_unique": { + "name": "repo_branches_repo_name_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repository_connections": { + "name": "repository_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "source_control_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_account": { + "name": "provider_account", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_token": { + "name": "encrypted_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "source_control_connection_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repo_connections_user_idx": { + "name": "repo_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repo_connections_status_idx": { + "name": "repo_connections_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repository_connections_user_id_users_id_fk": { + "name": "repository_connections_user_id_users_id_fk", + "tableFrom": "repository_connections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "repo_connections_user_provider_account_unique": { + "name": "repo_connections_user_provider_account_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "provider", + "provider_account" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.activity_event_type": { + "name": "activity_event_type", + "schema": "public", + "values": [ + "task_created", + "task_status_changed", + "agent_action", + "chat_started", + "review_action", + "skill_created", + "project_created", + "project_status_changed", + "goal_created", + "strategy_submitted", + "strategy_approved", + "strategy_rejected" + ] + }, + "public.agent_kind": { + "name": "agent_kind", + "schema": "public", + "values": [ + "orchestrator", + "worker", + "evaluator" + ] + }, + "public.agent_status": { + "name": "agent_status", + "schema": "public", + "values": [ + "active", + "paused", + "idle" + ] + }, + "public.automation_conversation_mode": { + "name": "automation_conversation_mode", + "schema": "public", + "values": [ + "new_each_run", + "reuse" + ] + }, + "public.automation_mode": { + "name": "automation_mode", + "schema": "public", + "values": [ + "chat_followup", + "research", + "maintenance" + ] + }, + "public.automation_output_target": { + "name": "automation_output_target", + "schema": "public", + "values": [ + "chat_session", + "review_inbox" + ] + }, + "public.budget_action": { + "name": "budget_action", + "schema": "public", + "values": [ + "block", + "notify_only" + ] + }, + "public.budget_period": { + "name": "budget_period", + "schema": "public", + "values": [ + "day", + "week", + "month", + "run" + ] + }, + "public.budget_scope": { + "name": "budget_scope", + "schema": "public", + "values": [ + "global", + "project", + "agent", + "run" + ] + }, + "public.budget_trigger_type": { + "name": "budget_trigger_type", + "schema": "public", + "values": [ + "warn", + "block" + ] + }, + "public.evaluation_verdict": { + "name": "evaluation_verdict", + "schema": "public", + "values": [ + "pass", + "fail", + "needs_revision" + ] + }, + "public.audit_action": { + "name": "audit_action", + "schema": "public", + "values": [ + "settings.updated", + "settings.reset", + "agent.config.updated", + "agent.created", + "agent.deleted", + "agent.status.changed", + "budget_limit.created", + "budget_limit.updated", + "budget_limit.deleted", + "skill.deleted", + "user.created", + "user.deactivated", + "user.role.changed" + ] + }, + "public.hook_kind": { + "name": "hook_kind", + "schema": "public", + "values": [ + "builtin", + "skill" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "pending", + "leased", + "running", + "retry_wait", + "completed", + "failed", + "canceled" + ] + }, + "public.memory_drawer_role": { + "name": "memory_drawer_role", + "schema": "public", + "values": [ + "user", + "assistant", + "system", + "note" + ] + }, + "public.memory_exclusion_kind": { + "name": "memory_exclusion_kind", + "schema": "public", + "values": [ + "regex", + "substring" + ] + }, + "public.memory_recall_source": { + "name": "memory_recall_source", + "schema": "public", + "values": [ + "chat", + "agent", + "search", + "bench" + ] + }, + "public.memory_wing_kind": { + "name": "memory_wing_kind", + "schema": "public", + "values": [ + "person", + "project", + "topic", + "agent" + ] + }, + "public.monitor_action": { + "name": "monitor_action", + "schema": "public", + "values": [ + "start_conversation", + "review_item", + "push", + "run_automation" + ] + }, + "public.monitor_condition_kind": { + "name": "monitor_condition_kind", + "schema": "public", + "values": [ + "tool_result", + "model_question" + ] + }, + "public.monitor_status": { + "name": "monitor_status", + "schema": "public", + "values": [ + "active", + "paused", + "fired", + "expired", + "exhausted", + "failed", + "canceled" + ] + }, + "public.log_level": { + "name": "log_level", + "schema": "public", + "values": [ + "debug", + "info", + "warn", + "error" + ] + }, + "public.review_item_severity": { + "name": "review_item_severity", + "schema": "public", + "values": [ + "info", + "warning", + "critical" + ] + }, + "public.review_item_status": { + "name": "review_item_status", + "schema": "public", + "values": [ + "open", + "in_progress", + "resolved", + "dismissed" + ] + }, + "public.review_item_type": { + "name": "review_item_type", + "schema": "public", + "values": [ + "approval_request", + "user_question", + "evaluation_failure", + "job_failure", + "job_stuck", + "hook_failure", + "memory_conflict", + "policy_override_request", + "pull_request_ready", + "pull_request_checks_failed", + "automation_summary", + "monitor_fired" + ] + }, + "public.run_trace_status": { + "name": "run_trace_status", + "schema": "public", + "values": [ + "running", + "completed", + "failed", + "canceled" + ] + }, + "public.project_kind": { + "name": "project_kind", + "schema": "public", + "values": [ + "efoil", + "research", + "code", + "documentation", + "other" + ] + }, + "public.research_status": { + "name": "research_status", + "schema": "public", + "values": [ + "planning", + "searching", + "fetching", + "reflecting", + "synthesizing", + "complete", + "failed", + "canceled" + ] + }, + "public.research_step_kind": { + "name": "research_step_kind", + "schema": "public", + "values": [ + "plan", + "search", + "fetch", + "extract", + "synthesize", + "note" + ] + }, + "public.chat_run_source": { + "name": "chat_run_source", + "schema": "public", + "values": [ + "chat_stream", + "agent_subagent", + "automation" + ] + }, + "public.chat_run_state": { + "name": "chat_run_state", + "schema": "public", + "values": [ + "queued", + "running", + "waiting_tool_approval", + "waiting_user_input", + "waiting_plan_decision", + "completed", + "failed", + "canceled" + ] + }, + "public.conversation_permission_mode": { + "name": "conversation_permission_mode", + "schema": "public", + "values": [ + "default", + "plan", + "acceptEdits", + "bypassPermissions" + ] + }, + "public.message_role": { + "name": "message_role", + "schema": "public", + "values": [ + "user", + "assistant", + "system", + "tool" + ] + }, + "public.pull_request_check_status": { + "name": "pull_request_check_status", + "schema": "public", + "values": [ + "pending", + "running", + "success", + "failure", + "canceled", + "skipped" + ] + }, + "public.pull_request_status": { + "name": "pull_request_status", + "schema": "public", + "values": [ + "draft", + "open", + "merged", + "closed" + ] + }, + "public.source_control_connection_status": { + "name": "source_control_connection_status", + "schema": "public", + "values": [ + "active", + "error", + "revoked", + "pending" + ] + }, + "public.source_control_provider": { + "name": "source_control_provider", + "schema": "public", + "values": [ + "github", + "gitlab", + "bitbucket", + "gitea", + "local" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 14da37f1..5a8d7709 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -554,6 +554,13 @@ "when": 1790180099778, "tag": "0078_previous_storm", "breakpoints": true + }, + { + "idx": 79, + "version": "7", + "when": 1790201242224, + "tag": "0079_conversation_lifecycle", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/chat-console/ConsoleNavContent.svelte b/src/lib/chat-console/ConsoleNavContent.svelte index 7dc6407b..6151e8fa 100644 --- a/src/lib/chat-console/ConsoleNavContent.svelte +++ b/src/lib/chat-console/ConsoleNavContent.svelte @@ -2,12 +2,15 @@ import { browser } from '$app/environment'; import { onMount } from 'svelte'; import favicon from '$lib/assets/favicon.svg'; - import { getConversations } from '$lib/chat'; + import { getArchivedConversations, getConversations, searchConversations } from '$lib/chat'; import { onConversationListChange } from '$lib/chat/conversation-list-sync'; + import { SEARCH_QUERY_MAX_CHARS, SEARCH_QUERY_MIN_CHARS, splitSnippet } from '$lib/chat/conversation-search'; + import { fetchFresh } from '$lib/ui/fresh-query'; import { page } from '$app/state'; import { getCredits, refreshCredits } from '$lib/llm/credits.remote'; import Icon from './Icon.svelte'; - import { dayKey, dayLabel } from '$lib/util/relative-time'; + import ConversationRowMenu from './ConversationRowMenu.svelte'; + import { groupConversations, listTime, type ConversationListView } from '$lib/chat/conversation-order'; const THEME_STORAGE_KEY = 'AgentStudio-theme'; let isDark = $state(true); @@ -28,6 +31,7 @@ } type Conversation = Awaited>[number]; + type SearchHit = Awaited>[number]; type LiveRun = { id: string; @@ -63,7 +67,6 @@ * failed read (no session yet) is just an empty list, as before. */ const conversationsQuery = $derived(browser && authenticated ? getConversations() : null); - const conversations = $derived(conversationsQuery?.current ?? []); function formatUsd(value: number): string { if (value >= 100) return `$${value.toFixed(0)}`; @@ -83,14 +86,29 @@ } let chatFilter = $state(''); let openMenu = $state(false); + /** `All` is every unarchived chat, `Running` those with a live turn, `Archived` the archive (#18). */ let filters = $state({ status: 'All', project: 'All', env: 'All', lastActivity: 'All' }); let groupBy = $state<'Project' | 'Status' | 'Environment' | 'Date' | 'None'>('Date'); let sortBy = $state<'Recency' | 'Name' | 'Project'>('Recency'); + /* + * #18 — the archive is its own list, read only while it is being looked at. The default + * list leaves archived chats out on the server. + */ + const showingArchive = $derived(filters.status === 'Archived'); + const archivedQuery = $derived(browser && authenticated && showingArchive ? getArchivedConversations() : null); + const conversations = $derived( + (showingArchive ? archivedQuery?.current : conversationsQuery?.current) ?? [], + ); + /** Fetched when opened: until it arrives the archive is loading, not empty. */ + const archiveLoading = $derived(archivedQuery !== null && !archivedQuery.ready && !archivedQuery.error); + onMount(() => { if (!browser) return; const source = new EventSource('/api/chat/monitor'); - onConversationListChange(source, () => getConversations().refresh()); + onConversationListChange(source, () => + Promise.all([getConversations().refresh(), showingArchive ? getArchivedConversations().refresh() : null]), + ); source.onmessage = (event) => { try { const runs = JSON.parse(event.data) as LiveRun[]; @@ -124,53 +142,64 @@ const isActiveRun = (c: Conversation) => Boolean(runFor(c)); return conversations.filter((c) => { if (q && !(c.title.toLowerCase().includes(q) || (c.lastMessage?.toLowerCase().includes(q)))) return false; - if (filters.status === 'Active' && !isActiveRun(c)) return false; - if (filters.status === 'Archived' && isActiveRun(c)) return false; + if (filters.status === 'Running' && !isActiveRun(c)) return false; return true; }); }); - const sorted = $derived.by(() => { - const arr = [...filtered]; - if (sortBy === 'Name') return arr.sort((a, b) => a.title.localeCompare(b.title)); - if (sortBy === 'Project') { - return arr.sort((a, b) => { - const ap = a.category ?? 'Uncategorized'; - const bp = b.category ?? 'Uncategorized'; - return ap.localeCompare(bp); - }); + /* + * #18 — the order is `$lib/chat/conversation-order`'s: pinned chats on top by when they were + * pinned, the archive by when each chat was archived, everything else by last activity. + */ + const listView = $derived(showingArchive ? 'archive' : 'chats'); + const grouped = $derived(groupConversations(filtered, { sortBy, groupBy, view: listView })); + + /* + * #18 — search in messages and tool calls, on the server. The filter above stays instant + * over the loaded titles; this runs a quarter-second after typing stops, and finds chats + * the list does not hold (older than the recent 50, or archived while the archive is open). + */ + let searchHits = $state([]); + let searchStatus = $state<'idle' | 'loading' | 'done' | 'error'>('idle'); + let searchNonce = $state(0); + let searchSeq = 0; + + $effect(() => { + const q = chatFilter.trim().slice(0, SEARCH_QUERY_MAX_CHARS); + const includeArchived = showingArchive; + void searchNonce; + const seq = ++searchSeq; + if (!browser || !authenticated || q.length < SEARCH_QUERY_MIN_CHARS) { + searchHits = []; + searchStatus = 'idle'; + return; } - return arr.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + searchStatus = 'loading'; + const timer = setTimeout(async () => { + try { + const hits = await fetchFresh(searchConversations({ q, includeArchived })); + if (seq !== searchSeq) return; + searchHits = hits; + searchStatus = 'done'; + } catch { + if (seq !== searchSeq) return; + searchHits = []; + searchStatus = 'error'; + } + }, 250); + return () => clearTimeout(timer); }); - const grouped = $derived.by(() => { - if (groupBy === 'None') return [['', sorted] as [string, Conversation[]]]; - if (groupBy === 'Date') { - const m = new Map(); - for (const c of sorted) { - const key = dayKey(c.updatedAt); - const existing = m.get(key); - if (existing) existing.items.push(c); - else { - const ds = new Date(c.updatedAt); - ds.setHours(0, 0, 0, 0); - m.set(key, { label: dayLabel(c.updatedAt), ts: ds.getTime(), items: [c] }); - } - } - return [...m.values()] - .sort((a, b) => b.ts - a.ts) - .map((g) => [g.label, g.items] as [string, Conversation[]]); - } - const fieldKey: keyof Conversation = groupBy === 'Project' ? 'category' : groupBy === 'Status' ? 'category' : 'category'; - const m = new Map(); - for (const c of sorted) { - const key = (c[fieldKey] as string | null) ?? 'Uncategorized'; - if (!m.has(key)) m.set(key, []); - m.get(key)!.push(c); - } - return [...m.entries()]; + /** Server hits worth showing: a matching message, or a title the loaded list does not hold. */ + const messageHits = $derived.by(() => { + const shown = new Set(filtered.map((c) => c.id)); + return searchHits.filter((hit) => hit.match || !shown.has(hit.conversationId)); }); + function rerunSearch() { + searchNonce += 1; + } + const activeChatId = $derived.by(() => { const match = /^\/chat\/([^/]+)$/.exec(activePath); return match?.[1] ?? null; @@ -268,10 +297,10 @@
(openMenu = false)}>
Status - + +
@@ -296,37 +325,88 @@ {/if}
- {#each grouped as [label, items] (label || 'flat')} - {#if label} -
- {label} - {items.length} + {#if showingArchive} +
+ Archived chats + +
+ {/if} + {#each grouped as group (group.key)} + {#if group.label} +
+ {group.label} + {group.items.length}
{/if} - {#each items as conversation (conversation.id)} + {#each group.items as conversation (conversation.id)} {@const run = runFor(conversation)} - - - {#if run} - - {:else} - - {/if} - - {conversation.title} - · {relativeShort(conversation.updatedAt)} - + {/each} {/each} - {#if conversations.length === 0} -
No conversations yet
- {:else if sorted.length === 0} + {#if archiveLoading} +
Loading…
+ {:else if conversations.length === 0 && messageHits.length === 0} +
{showingArchive ? 'Nothing archived.' : 'No conversations yet'}
+ {:else if filtered.length === 0 && searchStatus === 'idle'} +
No chats match.
{/if} + + {#if searchStatus !== 'idle'} +
+
+ In messages + {searchStatus === 'loading' ? '…' : messageHits.length} +
+ {#if searchStatus === 'error'} +
Search failed. Try again.
+ {:else if searchStatus === 'done' && messageHits.length === 0} +
Nothing found in messages.
+ {/if} + {#each messageHits as hit (hit.conversationId)} + {@const when = new Date(hit.match?.createdAt ?? hit.updatedAt)} + + + {hit.title} + {#if hit.archived} + archived + {/if} + + + {#if hit.match} + + {#each splitSnippet(hit.match.snippet) as part, i (i)}{#if part.mark}{part.text}{:else}{part.text}{/if}{/each} + {/if} + + {/each} +
+ {/if}
diff --git a/src/lib/chat-console/ConversationRowMenu.svelte b/src/lib/chat-console/ConversationRowMenu.svelte new file mode 100644 index 00000000..b3abb903 --- /dev/null +++ b/src/lib/chat-console/ConversationRowMenu.svelte @@ -0,0 +1,271 @@ + + + + +
+ + +
+ +{#if open} + +
+ {#if renaming} +
+ +
+ + +
+
+ {:else} + + + + + + Export as Markdown + + + + Export as JSON + + + + {/if} + {#if errorMessage} + + {/if} +
+{/if} diff --git a/src/lib/chat-console/Icon.svelte b/src/lib/chat-console/Icon.svelte index c388f089..bca8c17e 100644 --- a/src/lib/chat-console/Icon.svelte +++ b/src/lib/chat-console/Icon.svelte @@ -3,7 +3,8 @@ | 'send' | 'arrow' | 'plus' | 'paper' | 'mic' | 'chat' | 'bolt' | 'chip' | 'school' | 'folder' | 'branch' | 'cog' | 'brain' | 'check' | 'x' | 'caret' | 'search' | 'file' | 'edit' | 'copy' | 'refresh' | 'sun' | 'moon' | 'dollar' | 'database' | 'clock' - | 'alert' | 'terminal' | 'grid' | 'globe' | 'external' | 'panel'; + | 'alert' | 'terminal' | 'grid' | 'globe' | 'external' | 'panel' + | 'pin' | 'archive' | 'download' | 'dots' | 'trash'; let { name, size = 14 }: { name: IconName; size?: number } = $props(); @@ -72,4 +73,14 @@ {:else if name === 'panel'} +{:else if name === 'pin'} + +{:else if name === 'archive'} + +{:else if name === 'download'} + +{:else if name === 'dots'} + +{:else if name === 'trash'} + {/if} diff --git a/src/lib/chat-console/console.css b/src/lib/chat-console/console.css index e7befe14..297bae72 100644 --- a/src/lib/chat-console/console.css +++ b/src/lib/chat-console/console.css @@ -2607,3 +2607,246 @@ a.console-crumbs__seg:hover { .console-bgtask__stop:hover:not(:disabled) { opacity: 0.95; } .console-bgtask__stop:disabled { opacity: 0.2; cursor: default; } + +/* ── #18 Conversation lifecycle: row actions, pinned group, archive, search ───── + Each sidebar chat row is wrapped in .console-chatitem so its actions can sit beside + the link rather than inside it. On a pointer device the actions appear on hover or + focus and cover the time label; on a touch screen or a phone-width drawer the "⋯" + button is always there and the row makes room for it. The menu opens inline under + the row, so the scrolling list and the drawer never clip it. */ + +/* One grid cell holds both the row and its actions, so the actions stay centred on the + row whether or not the menu below it is open. */ +.console-chatitem { + display: grid; + grid-template-columns: minmax(0, 1fr); +} + +.console-chatitem > .console-chatrow { grid-area: 1 / 1; } +.console-chatitem > .console-rowmenu { grid-area: 2 / 1; } + +.console-rowactions { + grid-area: 1 / 1; + justify-self: end; + align-self: center; + z-index: 1; + margin-right: 4px; + display: flex; + align-items: center; + gap: 2px; + padding-left: 6px; + border-radius: 4px; + background: var(--color-base-200); + opacity: 0; + /* Hidden means untouchable too: a tap on the time label must not archive the chat. */ + pointer-events: none; + transition: opacity 100ms ease-out; +} + +.console-chatitem:hover .console-rowactions, +.console-chatitem:focus-within .console-rowactions, +.console-rowactions.is-open { + opacity: 1; + pointer-events: auto; +} + +.console-rowactions__btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 20px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: color-mix(in oklab, var(--color-base-content) 60%, transparent); + cursor: pointer; +} + +.console-rowactions__btn:hover:not(:disabled), +.console-rowactions__btn[aria-expanded='true'] { + background: var(--color-base-300); + color: var(--color-base-content); +} + +.console-rowactions__btn:disabled { opacity: 0.4; cursor: default; } + +@media (hover: none), (max-width: 47.99rem) { + .console-chatitem .console-chatrow { padding-right: 36px; } + .console-rowactions { + opacity: 1; + pointer-events: auto; + background: transparent; + } + /* One-tap archive is a hover convenience; on touch it lives in the menu. */ + .console-rowactions__quick { display: none; } + .console-rowactions__btn { width: 30px; height: 26px; } +} + +.console-rowmenu { + display: flex; + flex-direction: column; + gap: 1px; + margin: 2px 6px 6px 14px; + padding: 4px; + background: var(--color-base-200); + border: 1px solid var(--color-base-300); + border-radius: 6px; + box-shadow: 0 6px 18px oklch(0% 0 0 / 0.25); + font-family: Consolas, 'Cascadia Code', monospace; +} + +.console-rowmenu__item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--color-base-content); + font: inherit; + font-size: 11.5px; + text-align: left; + text-decoration: none; + cursor: pointer; +} + +.console-rowmenu__item:hover:not(:disabled), +.console-rowmenu__item:focus-visible { + background: var(--color-base-300); + outline: none; +} + +.console-rowmenu__item.is-primary { color: var(--color-primary); font-weight: 600; } +.console-rowmenu__item.is-danger { color: var(--color-error); } +.console-rowmenu__item:disabled { opacity: 0.5; cursor: default; } +.console-rowmenu__sep { height: 1px; margin: 3px 2px; background: var(--color-base-300); } + +.console-rowmenu__rename { + display: flex; + flex-direction: column; + gap: 6px; + padding: 4px; +} + +.console-rowmenu__rename input { + width: 100%; + min-width: 0; + padding: 5px 7px; + border: 1px solid var(--color-base-300); + border-radius: 4px; + background: var(--color-base-100); + color: var(--color-base-content); + font: inherit; + font-size: 12px; +} + +.console-rowmenu__renamebtns { display: flex; justify-content: flex-end; gap: 4px; } +.console-rowmenu__renamebtns .console-rowmenu__item { width: auto; } + +.console-rowmenu__error { + margin: 4px 6px 2px; + font-size: 11px; + color: var(--color-error); +} + +.console-chatgroup.is-pinned { color: color-mix(in oklab, var(--color-primary) 80%, transparent); } + +.console-archivebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: 2px 6px 4px; + padding: 5px 8px; + border: 1px dashed var(--color-base-300); + border-radius: 5px; + font-family: Consolas, 'Cascadia Code', monospace; + font-size: 11px; + color: color-mix(in oklab, var(--color-base-content) 70%, transparent); +} + +.console-archivebar button { + padding: 2px 4px; + border: 0; + background: none; + color: var(--color-primary); + font: inherit; + cursor: pointer; +} + +.console-searchhits { + display: flex; + flex-direction: column; + gap: 1px; + margin-top: 4px; + border-top: 1px dashed var(--color-base-300); +} + +.console-searchhit { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0 2px; + padding: 5px 8px; + border-radius: 4px; + color: var(--color-base-content); + text-decoration: none; + font-family: Consolas, 'Cascadia Code', monospace; + font-size: 12px; +} + +.console-searchhit:hover, +.console-searchhit.active { background: var(--color-base-200); } + +.console-searchhit__head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: baseline; + gap: 6px; +} + +.console-searchhit__head .t { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: 500; +} + +.console-searchhit__head .s { + font-size: 10.5px; + color: color-mix(in oklab, var(--color-base-content) 50%, transparent); +} + +.console-searchhit__badge { + padding: 0 4px; + border-radius: 3px; + background: var(--color-base-300); + font-size: 9.5px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: color-mix(in oklab, var(--color-base-content) 70%, transparent); +} + +.console-searchhit__snippet { + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; + font-size: 11px; + line-height: 1.4; + color: color-mix(in oklab, var(--color-base-content) 65%, transparent); +} + +.console-searchhit__snippet mark { + padding: 0 1px; + border-radius: 2px; + background: color-mix(in oklab, var(--color-primary) 28%, transparent); + color: var(--color-base-content); +} diff --git a/src/lib/chat/chat.remote.ts b/src/lib/chat/chat.remote.ts index 888ed348..7cc29223 100644 --- a/src/lib/chat/chat.remote.ts +++ b/src/lib/chat/chat.remote.ts @@ -1,4 +1,5 @@ import { command, query } from '$app/server' +import { error } from '@sveltejs/kit' import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm' import { z } from 'zod' import { db } from '$lib/db.server' @@ -18,9 +19,12 @@ import { BUILTIN_AGENT_KEYS } from '$lib/agents/builtin-agents.server' import { insertMessageWithSequence } from '$lib/chat/insert-message.server' import { editUserMessage, truncateAfterMessage } from '$lib/chat/message-branch.server' import { previewMessageRewind } from '$lib/chat/rewind.server' -import { listRecentConversations } from '$lib/chat/conversation-list.server' -import { findLiveChatRun } from '$lib/runs/live-chat-run.server' +import { listArchivedConversations, listRecentConversations } from '$lib/chat/conversation-list.server' +import { setConversationArchivedForUser, setConversationPinnedForUser } from '$lib/chat/conversation-lifecycle.server' import { deleteConversationForUser } from '$lib/chat/conversation-delete.server' +import { searchUserConversations } from '$lib/chat/message-search.server' +import { SEARCH_QUERY_MAX_CHARS, SEARCH_QUERY_MIN_CHARS } from '$lib/chat/conversation-search' +import { findLiveChatRun } from '$lib/runs/live-chat-run.server' import { describePermissionMode, PERMISSION_MODES, @@ -154,15 +158,19 @@ export const createConversation = command(createConversationSchema, async (input return created }) +/** + * #18, #35 — stops a turn still running in the conversation first, background commands and + * delegated agents included, and waits for it to wind down; see `deleteConversationForUser`. + */ export const deleteConversation = command(conversationIdSchema, async (conversationId) => { const user = requireAuthenticatedRequestUser() - // Stops a turn still running in it first, background commands included (#35). await deleteConversationForUser(user.id, conversationId) return { success: true } }) export const editMessage = command(editMessageSchema, async (input) => { const user = requireAuthenticatedRequestUser() + // #18 — the edited message is re-indexed for search there, once its cut is committed. return editUserMessage({ ...input, userId: user.id }) }) @@ -390,3 +398,49 @@ export const listAgentsForPicker = query(async () => { .from(agents) .orderBy(builtinOrder, asc(agents.createdAt)) }) + +/** + * #18 — the archive: archived conversations, most recently archived first. Same row shape + * as `getConversations`, which leaves them out. + */ +export const getArchivedConversations = query(async () => { + const user = requireAuthenticatedRequestUser() + return listArchivedConversations(user.id) +}) + +const setConversationPinnedSchema = z.object({ id: z.string().uuid(), pinned: z.boolean() }) + +/** #18 — pin to the top of the sidebar, or unpin. Pinning an archived chat unarchives it. */ +export const setConversationPinned = command(setConversationPinnedSchema, async ({ id, pinned }) => { + const user = requireAuthenticatedRequestUser() + const state = await setConversationPinnedForUser(user.id, id, pinned) + if (!state) error(404, 'Conversation not found') + return { success: true as const, ...state } +}) + +const setConversationArchivedSchema = z.object({ id: z.string().uuid(), archived: z.boolean() }) + +/** + * #18 — archive (hide from the list, keep everything) or unarchive. The primary way to tidy + * the sidebar; `deleteConversation` is the irreversible one. Archiving unpins. + */ +export const setConversationArchived = command(setConversationArchivedSchema, async ({ id, archived }) => { + const user = requireAuthenticatedRequestUser() + const state = await setConversationArchivedForUser(user.id, id, archived) + if (!state) error(404, 'Conversation not found') + return { success: true as const, ...state } +}) + +const searchConversationsSchema = z.object({ + q: z.string().trim().min(SEARCH_QUERY_MIN_CHARS).max(SEARCH_QUERY_MAX_CHARS), + includeArchived: z.boolean().optional(), +}) + +/** + * #18 — search the caller's conversations: message text and tool calls (file paths, + * commands, links), plus titles. One hit per conversation with the best-matching snippet. + */ +export const searchConversations = query(searchConversationsSchema, async ({ q, includeArchived }) => { + const user = requireAuthenticatedRequestUser() + return searchUserConversations(user.id, q, { includeArchived: includeArchived === true }) +}) diff --git a/src/lib/chat/conversation-delete.server.ts b/src/lib/chat/conversation-delete.server.ts index a43ef0a6..d6a5177e 100644 --- a/src/lib/chat/conversation-delete.server.ts +++ b/src/lib/chat/conversation-delete.server.ts @@ -1,34 +1,119 @@ -import { and, eq } from 'drizzle-orm' +/** + * Deleting a conversation (#18, #35) — the irreversible end of its lifecycle. Archiving is + * the reversible one (`./conversation-lifecycle.server`); an archived conversation is deleted + * exactly like any other. + * + * The delete itself is one statement: everything that belongs to the conversation (messages, + * runs, their events, the search index) goes with it by cascade. What needs care is a turn + * that is still running in it. + * + * Deleting under a live turn used to leave that turn running with nothing to stop it: the + * cascade removes its `chat_runs` row, Stop finds runs through that row, and so from then on + * Stop answered "not active" while the agent kept running tools in the sandbox — the CLI + * session, the commands it had put in the background, the dev server one of them started. + * Its final write then failed on a conversation that no longer existed. + * + * So the live turn is stopped first, the way the Stop button stops it (the run registry's + * `interruptRun`). With no `perTaskStopAffordance` declared (`options.server` leaves it + * unset), the CLI's interrupt also kills the session's background tasks and delegated + * agents (`sdk.d.ts`), and the turn then ends, which closes the session (#35, #32). The turn + * is given a moment to wind down: an interrupted turn ends with an ordinary result, saves + * its partial reply and closes its run row, and only then lets go of its claim. The delete + * waits for that, up to `RUN_SETTLE_MS`, and goes ahead regardless once the time is up — a + * turn that has been told to stop will stop, and a delete must not hang on one that is slow + * to. + * + * A turn another process is running (an automation in the jobs worker) cannot be reached + * from here, exactly as Stop cannot reach it; it is short and bounded, and its writes simply + * fail once the conversation is gone. + * + * Ownership is checked before anything is stopped, and is the WHERE clause on the delete. + */ + +import { and, eq, isNull } from 'drizzle-orm' import { db } from '$lib/db.server' import { conversations } from '$lib/sessions/sessions.schema' -import { stopChatRun } from '$lib/runs/runs.server' +import { chatRuns } from '$lib/runs/runs.schema' +import { interruptRun, isRunClaimedHere } from '$lib/engine/run-registry.server' +import { logger } from '$lib/observability/logger' + +/** How long a delete waits for a stopped turn to wind down before deleting anyway. */ +export const RUN_SETTLE_MS = 10_000 +const RUN_SETTLE_POLL_MS = 100 + +export const DELETE_STOP_REASON = 'The conversation was deleted' + +export type DeleteConversationResult = { + /** False when there was no such conversation for this user. */ + deleted: boolean + /** Turns that were running here and were stopped first. */ + stoppedRuns: number +} /** - * Delete a conversation, stopping whatever it is still running first (#35). - * - * Deleting only removed the row. A turn in progress kept going — the CLI session, the - * commands it had put in the background, the dev server one of them started — with nothing - * left in the app that could reach it: its run rows cascade away with the conversation, so - * no Stop could find it afterwards. So the live run is interrupted first, through the same - * path as the Stop button. With no `perTaskStopAffordance` declared (`options.server` leaves - * it unset), the CLI's interrupt also kills the session's background tasks (`sdk.d.ts`), and - * the turn then ends, which closes the session. + * Delete one of the user's conversations, stopping its live turn first. Another user's + * conversation is neither deleted nor has its turn touched: ownership is checked before + * anything is stopped. * - * Stop first, then delete: once the row is gone, nothing can find the run by its - * conversation. The interrupted turn still tries to save its reply into the deleted - * conversation; that write fails and is logged by the stream route, which is the right - * outcome for a reply nobody can see. - * - * Ownership is the WHERE clause on both halves, as everywhere else. + * `settleMs` and `pollMs` exist for the spec; the remote function passes neither. */ export async function deleteConversationForUser( userId: string, conversationId: string, -): Promise<{ deleted: boolean; stoppedRun: boolean }> { - const stop = await stopChatRun({ userId, conversationId, reason: 'Conversation deleted' }) + options: { settleMs?: number; pollMs?: number } = {}, +): Promise { + const settleMs = options.settleMs ?? RUN_SETTLE_MS + const pollMs = options.pollMs ?? RUN_SETTLE_POLL_MS + + const [owned] = await db + .select({ id: conversations.id }) + .from(conversations) + .where(and(eq(conversations.id, conversationId), eq(conversations.userId, userId))) + .limit(1) + if (!owned) return { deleted: false, stoppedRuns: 0 } + + // Every unfinished run, whatever state it is waiting in: `interruptRun` ignores any this + // process is not running, so there is no harm in asking about all of them. + const open = await db + .select({ id: chatRuns.id }) + .from(chatRuns) + .where( + and(eq(chatRuns.conversationId, conversationId), eq(chatRuns.userId, userId), isNull(chatRuns.finishedAt)), + ) + + const stopping: string[] = [] + for (const { id } of open) { + if (await interruptRun(id, DELETE_STOP_REASON)) stopping.push(id) + } + + if (stopping.length > 0) { + const settled = await waitForRunsToEnd(stopping, settleMs, pollMs) + if (!settled) { + logger.warn('[chat] deleting a conversation whose stopped turn has not finished yet', { + conversationId, + runIds: stopping.filter(isRunClaimedHere), + settleMs, + }) + } + } + const deleted = await db .delete(conversations) .where(and(eq(conversations.id, conversationId), eq(conversations.userId, userId))) .returning({ id: conversations.id }) - return { deleted: deleted.length > 0, stoppedRun: stop.stopped } + + return { deleted: deleted.length > 0, stoppedRuns: stopping.length } +} + +/** + * Wait until none of `runIds` is claimed by this process any more — the turn's last act, after + * its reply and run row are written. True if they all ended in time. + */ +async function waitForRunsToEnd(runIds: string[], settleMs: number, pollMs: number): Promise { + const deadline = Date.now() + settleMs + while (runIds.some(isRunClaimedHere)) { + if (Date.now() >= deadline) return false + await new Promise((resolve) => setTimeout(resolve, pollMs)) + } + return true } diff --git a/src/lib/chat/conversation-export.server.ts b/src/lib/chat/conversation-export.server.ts new file mode 100644 index 00000000..1d196d6d --- /dev/null +++ b/src/lib/chat/conversation-export.server.ts @@ -0,0 +1,65 @@ +/** + * Loads a conversation for export (#18). Formatting is in ./conversation-export; this only + * reads, and only the caller's own conversation — anyone else's is indistinguishable from one + * that does not exist. + */ + +import { and, asc, eq } from 'drizzle-orm' +import { db } from '$lib/db.server' +import { conversations, messages } from '$lib/sessions/sessions.schema' +import { agents } from '$lib/agents/agents.schema' +import type { ExportInput } from '$lib/chat/conversation-export' + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +export async function loadConversationForExport(userId: string, conversationId: string): Promise { + if (!UUID_PATTERN.test(conversationId)) return null + + const [conversation] = await db + .select({ + id: conversations.id, + title: conversations.title, + category: conversations.category, + model: conversations.model, + agentId: conversations.agentId, + projectId: conversations.projectId, + permissionMode: conversations.permissionMode, + totalTokens: conversations.totalTokens, + totalCost: conversations.totalCost, + pinnedAt: conversations.pinnedAt, + archivedAt: conversations.archivedAt, + createdAt: conversations.createdAt, + updatedAt: conversations.updatedAt, + }) + .from(conversations) + .where(and(eq(conversations.id, conversationId), eq(conversations.userId, userId))) + .limit(1) + if (!conversation) return null + + const [rows, [agent]] = await Promise.all([ + db + .select({ + id: messages.id, + sequence: messages.sequence, + role: messages.role, + content: messages.content, + model: messages.model, + parentMessageId: messages.parentMessageId, + createdAt: messages.createdAt, + tokensIn: messages.tokensIn, + tokensOut: messages.tokensOut, + cost: messages.cost, + attachments: messages.attachments, + metadata: messages.metadata, + toolCalls: messages.toolCalls, + }) + .from(messages) + .where(eq(messages.conversationId, conversationId)) + .orderBy(asc(messages.sequence)), + conversation.agentId + ? db.select({ id: agents.id, name: agents.name }).from(agents).where(eq(agents.id, conversation.agentId)).limit(1) + : Promise.resolve([]), + ]) + + return { conversation, agent: agent ?? null, messages: rows } +} diff --git a/src/lib/chat/conversation-export.ts b/src/lib/chat/conversation-export.ts new file mode 100644 index 00000000..4b249eb8 --- /dev/null +++ b/src/lib/chat/conversation-export.ts @@ -0,0 +1,434 @@ +/** + * Export one conversation (#18): a readable Markdown transcript, and a JSON document with + * everything — every block, every tool call's full arguments and output. + * + * The two answer different needs. Markdown is for reading, pasting into a note or an issue, + * or handing to another model, so long tool output is cut short there with a pointer to the + * JSON. JSON is the archive: nothing is shortened, and it can be read back by a program. + * + * The transcript is the database's copy (`messages` and their `metadata.blocks`), which is + * what the chat page renders — not the Agent SDK's session file. + * + * Pure and dependency-free, so specs can call it in the plain Playwright loader. + */ + +import type { ToolResultDetails } from '../engine/tool-result-details' + +export const EXPORT_FORMAT = 'agentstudio.conversation' +export const EXPORT_VERSION = 1 + +/** Markdown caps. The JSON export has none. */ +const MD_SHELL_OUTPUT_CHARS = 4_000 +const MD_ARGUMENTS_CHARS = 1_000 +const MD_RESULT_CHARS = 2_000 +const MD_SYSTEM_NOTE_CHARS = 300 +const TRUNCATED_NOTE = '(truncated — see the JSON export for the full output)' + +export type ExportConversation = { + id: string + title: string + category: string | null + model: string + agentId: string | null + projectId: string | null + permissionMode: string + totalTokens: number + totalCost: string + pinnedAt: Date | null + archivedAt: Date | null + createdAt: Date + updatedAt: Date +} + +export type ExportAttachment = { id?: string; filename: string; mimeType: string; size: number; url?: string } + +export type ExportMessage = { + id: string + sequence: number + role: string + content: string + model: string | null + parentMessageId: string | null + createdAt: Date + tokensIn: number + tokensOut: number + cost: string + attachments: ExportAttachment[] + metadata: Record + toolCalls: Array> +} + +export type ExportInput = { + conversation: ExportConversation + agent: { id: string; name: string } | null + messages: ExportMessage[] + exportedAt?: Date +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +function str(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +/** `2026-09-23 14:02 UTC` — UTC so the same export reads the same wherever it is opened. */ +export function formatExportTime(value: Date | string): string { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + return `${date.toISOString().slice(0, 16).replace('T', ' ')} UTC` +} + +/** + * A code fence that the content cannot close: one backtick longer than the longest run of + * backticks inside it, and never shorter than three. Tool output is arbitrary text — a file + * with a Markdown code block in it would otherwise end the fence early and turn the rest of + * the transcript into garbage. + */ +export function codeFence(content: string, language = ''): string { + let longest = 0 + for (const match of content.matchAll(/`+/g)) longest = Math.max(longest, match[0].length) + const fence = '`'.repeat(Math.max(3, longest + 1)) + return `${fence}${language}\n${content.replace(/\n$/, '')}\n${fence}` +} + +/** Inline code the same way: enough backticks, padded when the content touches one. */ +function inlineCode(content: string): string { + const oneLine = content.replace(/\s*\n\s*/g, ' ') + let longest = 0 + for (const match of oneLine.matchAll(/`+/g)) longest = Math.max(longest, match[0].length) + const ticks = '`'.repeat(longest + 1) + const pad = oneLine.startsWith('`') || oneLine.endsWith('`') ? ' ' : '' + return `${ticks}${pad}${oneLine}${pad}${ticks}` +} + +function capped(text: string, max: number): { text: string; truncated: boolean } { + return text.length > max ? { text: text.slice(0, max), truncated: true } : { text, truncated: false } +} + +/** The tail, for command output — a failing command explains itself at the end. */ +function cappedTail(text: string, max: number): { text: string; truncated: boolean } { + return text.length > max ? { text: text.slice(-max), truncated: true } : { text, truncated: false } +} + +function formatBytes(size: number): string { + if (!Number.isFinite(size) || size < 0) return '' + if (size < 1024) return `${size} B` + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB` + return `${(size / (1024 * 1024)).toFixed(1)} MB` +} + +function formatCost(value: string | number): string { + const n = typeof value === 'number' ? value : Number.parseFloat(value) + if (!Number.isFinite(n)) return '$0' + return n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(4)}` +} + +function stringifyJson(value: unknown): string { + if (typeof value === 'string') { + try { + return JSON.stringify(JSON.parse(value), null, 2) + } catch { + return value + } + } + try { + return JSON.stringify(value, null, 2) ?? '' + } catch { + return String(value) + } +} + +function quote(text: string): string { + return text + .split('\n') + .map((line) => (line ? `> ${line}` : '>')) + .join('\n') +} + +function diffFromHunks(details: Extract): string { + const lines: string[] = [] + for (const hunk of asArray(details.hunks)) { + const h = asRecord(hunk) + if (!h) continue + lines.push(`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`) + for (const line of asArray(h.lines)) if (typeof line === 'string') lines.push(line) + } + return lines.join('\n') +} + +function toolMarkdown(block: Record): string { + const name = str(block.name) || 'tool' + const success = block.success === true || block.status === 'completed' + const failed = block.success === false || block.status === 'failed' || block.status === 'denied' + const mark = success ? ' ✓' : failed ? ' ✗' : '' + const details = asRecord(block.details) as ToolResultDetails | null + const out: string[] = [] + + if (details?.kind === 'file_edit') { + const counts = `(+${details.additions ?? 0} −${details.deletions ?? 0})` + out.push(`**Tool · ${name}** ${inlineCode(str(details.path))} ${counts}${mark}`) + const diff = diffFromHunks(details) + if (diff) out.push(codeFence(diff, 'diff')) + if (details.truncated) out.push(`_${TRUNCATED_NOTE}_`) + return out.join('\n\n') + } + + if (details?.kind === 'shell') { + const description = details.description ? ` — ${details.description}` : '' + out.push(`**Tool · ${name}**${mark}${description}`) + if (details.command) out.push(codeFence(`$ ${details.command}`, 'sh')) + const stdout = cappedTail(str(details.stdout), MD_SHELL_OUTPUT_CHARS) + const stderr = cappedTail(str(details.stderr), MD_SHELL_OUTPUT_CHARS) + if (stdout.text.trim()) out.push(codeFence(stdout.text, 'text')) + if (stderr.text.trim()) out.push(`stderr:\n\n${codeFence(stderr.text, 'text')}`) + if (stdout.truncated || stderr.truncated || details.truncated) out.push(`_${TRUNCATED_NOTE}_`) + return out.join('\n\n') + } + + if (details?.kind === 'todo') { + out.push(`**Tool · ${name}**${mark}`) + const items = asArray(details.items) + .map((item) => asRecord(item)) + .filter((item): item is Record => item !== null) + .map((item) => `- [${item.status === 'completed' ? 'x' : ' '}] ${str(item.content)}`) + if (items.length > 0) out.push(items.join('\n')) + return out.join('\n\n') + } + + out.push(`**Tool · ${name}**${mark}`) + const args = block.arguments + const argsText = args === undefined || args === null || args === '' ? '' : stringifyJson(args) + if (argsText && argsText !== '{}') { + const a = capped(argsText, MD_ARGUMENTS_CHARS) + out.push(codeFence(a.text, 'json')) + if (a.truncated) out.push(`_${TRUNCATED_NOTE}_`) + } + const resultText = typeof block.result === 'string' ? block.result : block.result == null ? '' : stringifyJson(block.result) + if (resultText.trim()) { + const r = capped(resultText, MD_RESULT_CHARS) + out.push(`Result:\n\n${codeFence(r.text, 'text')}`) + if (r.truncated) out.push(`_${TRUNCATED_NOTE}_`) + } + return out.join('\n\n') +} + +/** + * A delegated child's card, as a quote: who it was and what it was asked, the tools it called + * with what each touched, what it said (or its final report, when it said nothing on the way), + * and why it did not finish when it did not. + * + * Since #32 the card is the delegation's only record (there is no `tool` block beside it), + * its calls are on `transcript`, and a child the user stopped is `stopped` rather than + * failed. A card persisted before #32 has only `content` and `success`, and reads as before. + */ +function subagentMarkdown(block: Record): string { + const stopped = block.status === 'stopped' + const failed = !stopped && (block.success === false || block.status === 'failed') + const status = stopped ? ' (stopped)' : failed ? ' ✗' : '' + const task = str(block.task).trim() + const head = `**Subagent · ${str(block.agentName) || 'subagent'}**${status}${task ? ` — ${task.replace(/\s*\n\s*/g, ' ')}` : ''}` + + const calls = asArray(block.transcript) + .map((entry) => asRecord(entry)) + .filter((call): call is Record => call?.kind === 'tool' && str(call.name).trim() !== '') + .map((call) => { + const label = str(call.label).trim() + return `- ${str(call.name).trim()}${label ? ` ${inlineCode(label)}` : ''}${call.success === false ? ' ✗' : ''}` + }) + + const said = str(block.content).trim() + const body = said || str(asRecord(block.details)?.report).trim() + const error = stopped || failed ? str(block.error).trim() : '' + + const parts = [head] + if (calls.length > 0) parts.push(calls.join('\n')) + if (body) parts.push(body) + if (error) parts.push(`_${error.replace(/\s*\n\s*/g, ' ')}_`) + return quote(parts.join('\n\n')) +} + +function blockMarkdown(block: Record): string { + switch (block.kind) { + case 'text': + return str(block.content).trim() + case 'thinking': { + const content = str(block.content).trim() + return content ? `
Thinking\n\n${content}\n\n
` : '' + } + case 'subagent': + return subagentMarkdown(block) + case 'notice': { + const notice = asRecord(block.notice) + const title = str(notice?.title).trim() + const detail = str(notice?.detail).trim() + return title ? `_${title}${detail ? ` — ${detail}` : ''}_` : '' + } + case 'tool': + return toolMarkdown(block) + default: + return typeof block.name === 'string' ? toolMarkdown(block) : '' + } +} + +function messageHeading(message: ExportMessage): string { + const when = formatExportTime(message.createdAt) + if (message.role === 'user') return `## You · ${when}` + if (message.role === 'assistant') return `## Assistant${message.model ? ` (${message.model})` : ''} · ${when}` + if (message.role === 'tool') return `## Tool · ${when}` + return `## System · ${when}` +} + +function systemNote(message: ExportMessage): string { + const metadata = asRecord(message.metadata) + if (metadata?.type === 'agent_anchor') return '_The conversation switched agents here._' + const note = capped(message.content.trim(), MD_SYSTEM_NOTE_CHARS) + return note.text ? quote(`_${note.text.replace(/\n+/g, ' ')}${note.truncated ? '…' : ''}_`) : '' +} + +function messageMarkdown(message: ExportMessage): string { + if (message.role === 'system') { + const note = systemNote(message) + return note ? `## System · ${formatExportTime(message.createdAt)}\n\n${note}` : '' + } + + const parts: string[] = [messageHeading(message)] + const metadata = asRecord(message.metadata) ?? {} + const blocks = asArray(metadata.blocks).map(asRecord).filter((b): b is Record => b !== null) + const legacyTools = blocks.length === 0 ? asArray(message.toolCalls).map(asRecord).filter((b): b is Record => b !== null) : [] + + const hasText = blocks.some((block) => block.kind === 'text' && str(block.content).trim()) + if (!hasText && message.content.trim()) parts.push(message.content.trim()) + for (const block of [...blocks, ...legacyTools]) { + const rendered = blockMarkdown(block) + if (rendered) parts.push(rendered) + } + + const attachments = asArray(message.attachments) + .map(asRecord) + .filter((a): a is Record => a !== null && typeof a.filename === 'string') + if (attachments.length > 0) { + parts.push( + `**Attachments:**\n\n${attachments + .map((a) => { + const size = typeof a.size === 'number' ? formatBytes(a.size) : '' + const info = [str(a.mimeType), size].filter(Boolean).join(', ') + return `- ${inlineCode(str(a.filename))}${info ? ` (${info})` : ''}` + }) + .join('\n')}`, + ) + } + if (metadata.stoppedByUser === true) parts.push('_Stopped by the user._') + return parts.join('\n\n') +} + +/** A readable transcript. Long tool output is shortened; the JSON export has all of it. */ +export function exportConversationMarkdown(input: ExportInput): string { + const { conversation, agent, messages } = input + const exportedAt = input.exportedAt ?? new Date() + const status = [conversation.pinnedAt ? 'Pinned' : '', conversation.archivedAt ? 'Archived' : ''].filter(Boolean) + const header = [ + `# ${conversation.title.replace(/\s*\n\s*/g, ' ').trim() || 'Untitled conversation'}`, + '', + `- Exported: ${formatExportTime(exportedAt)}`, + `- Started: ${formatExportTime(conversation.createdAt)}`, + `- Last activity: ${formatExportTime(conversation.updatedAt)}`, + ...(agent ? [`- Agent: ${agent.name}`] : []), + `- Model: ${conversation.model}`, + `- Tokens: ${conversation.totalTokens.toLocaleString('en-US')} · Cost: ${formatCost(conversation.totalCost)}`, + `- Messages: ${messages.length}`, + ...(status.length > 0 ? [`- Status: ${status.join(', ')}`] : []), + ].join('\n') + + const body = messages.map(messageMarkdown).filter(Boolean) + return `${[header, ...body].join('\n\n---\n\n')}\n` +} + +/** Everything, unshortened. The owner's id is left out; nothing else is. */ +export function exportConversationJson(input: ExportInput) { + const { conversation, agent, messages } = input + return { + format: EXPORT_FORMAT, + version: EXPORT_VERSION, + exportedAt: (input.exportedAt ?? new Date()).toISOString(), + conversation: { + id: conversation.id, + title: conversation.title, + category: conversation.category, + model: conversation.model, + agent: agent ? { id: agent.id, name: agent.name } : null, + projectId: conversation.projectId, + permissionMode: conversation.permissionMode, + totalTokens: conversation.totalTokens, + totalCost: conversation.totalCost, + pinnedAt: conversation.pinnedAt ? new Date(conversation.pinnedAt).toISOString() : null, + archivedAt: conversation.archivedAt ? new Date(conversation.archivedAt).toISOString() : null, + createdAt: new Date(conversation.createdAt).toISOString(), + updatedAt: new Date(conversation.updatedAt).toISOString(), + }, + messages: messages.map((message) => ({ + id: message.id, + sequence: message.sequence, + role: message.role, + content: message.content, + model: message.model, + parentMessageId: message.parentMessageId, + createdAt: new Date(message.createdAt).toISOString(), + tokensIn: message.tokensIn, + tokensOut: message.tokensOut, + cost: message.cost, + attachments: message.attachments, + toolCalls: message.toolCalls, + metadata: message.metadata, + })), + } +} + +/** + * Download file names for an export: an ASCII one for `filename=` and the readable one for + * `filename*=` (RFC 6266), both `-<yyyy-mm-dd>.<ext>`. + */ +export function exportFileNames(title: string, date: Date, extension: 'md' | 'json'): { ascii: string; utf8: string } { + const day = new Date(date).toISOString().slice(0, 10) + const slug = + title + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) + .replace(/-+$/g, '') || 'conversation' + const readable = + firstCodePoints( + title + .replace(LONE_SURROGATE, '') + .replace(/[\u0000-\u001f\u007f/\\:*?"<>|]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(), + 80, + ).trim() || 'conversation' + return { ascii: `${slug}-${day}.${extension}`, utf8: `${readable}-${day}.${extension}` } +} + +/** Half of a surrogate pair with no other half: not valid Unicode, and `encodeURIComponent` throws on it. */ +const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g + +/** + * The first `max` characters of `text`, counted in code points. `slice` counts UTF-16 units, + * so cutting there can split an emoji into a lone surrogate. + */ +function firstCodePoints(text: string, max: number): string { + return Array.from(text).slice(0, max).join('') +} + +/** The `Content-Disposition` header value for an export download. */ +export function exportContentDisposition(names: { ascii: string; utf8: string }): string { + const encoded = encodeURIComponent(names.utf8).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`) + return `attachment; filename="${names.ascii}"; filename*=UTF-8''${encoded}` +} diff --git a/src/lib/chat/conversation-lifecycle.server.ts b/src/lib/chat/conversation-lifecycle.server.ts new file mode 100644 index 00000000..f264902d --- /dev/null +++ b/src/lib/chat/conversation-lifecycle.server.ts @@ -0,0 +1,72 @@ +/** + * Pin and archive (#18). + * + * Archive is the everyday way to tidy the sidebar: it hides a conversation from the default + * list and keeps everything — messages, runs, cost history and the memories mined from it. + * Delete stays available but is the exception, because it takes the runs and the memory + * links with it and cannot be undone. + * + * The rules, all enforced here rather than in the UI: + * - Pinning un-archives, and archiving un-pins. A chat is in exactly one of the pinned + * group, the normal list or the archive. + * - Neither touches `updatedAt`. That is the list's order ("latest activity"), and putting + * a chat away or pinning it is not activity — it would jump to the top otherwise. + * - A message the user sends brings an archived chat back (`unarchiveOnUserMessage`). + * Automations and monitors that post into a chat do not: a chat archived on purpose + * should not reappear every time a scheduled job writes to it. + */ + +import { and, eq, isNotNull } from 'drizzle-orm' +import { db } from '$lib/db.server' +import { conversations } from '$lib/sessions/sessions.schema' + +export type ConversationLifecycleState = { + id: string + pinnedAt: Date | null + archivedAt: Date | null +} + +const lifecycleColumns = { + id: conversations.id, + pinnedAt: conversations.pinnedAt, + archivedAt: conversations.archivedAt, +} + +/** Pin or unpin one of the user's conversations. Null when the user has no such conversation. */ +export async function setConversationPinnedForUser( + userId: string, + conversationId: string, + pinned: boolean, +): Promise<ConversationLifecycleState | null> { + const [row] = await db + .update(conversations) + .set(pinned ? { pinnedAt: new Date(), archivedAt: null } : { pinnedAt: null }) + .where(and(eq(conversations.id, conversationId), eq(conversations.userId, userId))) + .returning(lifecycleColumns) + return row ?? null +} + +/** Archive or unarchive one of the user's conversations. Null when the user has no such conversation. */ +export async function setConversationArchivedForUser( + userId: string, + conversationId: string, + archived: boolean, +): Promise<ConversationLifecycleState | null> { + const [row] = await db + .update(conversations) + .set(archived ? { archivedAt: new Date(), pinnedAt: null } : { archivedAt: null }) + .where(and(eq(conversations.id, conversationId), eq(conversations.userId, userId))) + .returning(lifecycleColumns) + return row ?? null +} + +/** + * The user wrote in an archived chat, so it is back in use. Called when the chat stream + * stores the user's message; nothing else calls it. A no-op for a chat that is not archived. + */ +export async function unarchiveOnUserMessage(conversationId: string): Promise<void> { + await db + .update(conversations) + .set({ archivedAt: null }) + .where(and(eq(conversations.id, conversationId), isNotNull(conversations.archivedAt))) +} diff --git a/src/lib/chat/conversation-list.server.ts b/src/lib/chat/conversation-list.server.ts index cf8c20ce..cf7eaa6f 100644 --- a/src/lib/chat/conversation-list.server.ts +++ b/src/lib/chat/conversation-list.server.ts @@ -10,22 +10,60 @@ * what the `(conversation_id, sequence)` unique index serves. */ -import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm' +import { and, desc, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm' import { db } from '$lib/db.server' import { conversations, messages } from '$lib/sessions/sessions.schema' import { chatRuns } from '$lib/runs/runs.schema' const RECENT_CONVERSATION_LIMIT = 50 +/** Pinned chats are listed in full on top of the recent ones, up to this many. */ +const PINNED_CONVERSATION_LIMIT = 100 +const ARCHIVED_CONVERSATION_LIMIT = 200 +/** + * The row's preview line. The sidebar and the home page each show one line of it, so the + * whole reply — a long one is tens of kilobytes — never needs to reach the browser. + */ +const LAST_MESSAGE_PREVIEW_CHARS = 280 const ACTIVE_RUN_STATES = new Set(['queued', 'running', 'waiting_tool_approval', 'waiting_user_input']) +type ConversationRow = typeof conversations.$inferSelect + +/** + * The default list (#18): every pinned conversation, then the most recent unpinned ones. + * Archived conversations are left out — they have their own list, + * `listArchivedConversations`, and come back when unarchived or written in. + */ export async function listRecentConversations(userId: string, limit = RECENT_CONVERSATION_LIMIT) { + const notArchived = and(eq(conversations.userId, userId), isNull(conversations.archivedAt)) + const [pinned, recent] = await Promise.all([ + db + .select() + .from(conversations) + .where(and(notArchived, isNotNull(conversations.pinnedAt))) + .orderBy(desc(conversations.pinnedAt)) + .limit(PINNED_CONVERSATION_LIMIT), + db + .select() + .from(conversations) + .where(and(notArchived, isNull(conversations.pinnedAt))) + .orderBy(desc(conversations.updatedAt)) + .limit(limit), + ]) + return withPreviewAndRun(userId, [...pinned, ...recent]) +} + +/** The archive (#18): archived conversations, most recently archived first. */ +export async function listArchivedConversations(userId: string, limit = ARCHIVED_CONVERSATION_LIMIT) { const rows = await db .select() .from(conversations) - .where(eq(conversations.userId, userId)) - .orderBy(desc(conversations.updatedAt)) + .where(and(eq(conversations.userId, userId), isNotNull(conversations.archivedAt))) + .orderBy(desc(conversations.archivedAt)) .limit(limit) + return withPreviewAndRun(userId, rows) +} +async function withPreviewAndRun(userId: string, rows: ConversationRow[]) { const conversationIds = rows.map((row) => row.id) if (conversationIds.length === 0) return [] @@ -33,7 +71,7 @@ export async function listRecentConversations(userId: string, limit = RECENT_CON db .selectDistinctOn([messages.conversationId], { conversationId: messages.conversationId, - content: messages.content, + content: sql<string>`left(${messages.content}, ${LAST_MESSAGE_PREVIEW_CHARS})`, }) .from(messages) .where(and(eq(messages.role, 'assistant'), inArray(messages.conversationId, conversationIds))) @@ -105,8 +143,21 @@ export async function conversationListVersion(userId: string): Promise<string> { .select({ count: sql<number>`count(*)::int`, latest: sql<string | null>`max(${conversations.updatedAt})::text`, + // Pinning and archiving deliberately leave `updatedAt` alone (#18), so they are + // counted and dated separately — another tab still hears about them. + pinned: sql<number>`count(${conversations.pinnedAt})::int`, + latestPin: sql<string | null>`max(${conversations.pinnedAt})::text`, + archived: sql<number>`count(${conversations.archivedAt})::int`, + latestArchive: sql<string | null>`max(${conversations.archivedAt})::text`, }) .from(conversations) .where(eq(conversations.userId, userId)) - return `${row?.count ?? 0}:${row?.latest ?? ''}` + return [ + row?.count ?? 0, + row?.latest ?? '', + row?.pinned ?? 0, + row?.latestPin ?? '', + row?.archived ?? 0, + row?.latestArchive ?? '', + ].join(':') } diff --git a/src/lib/chat/conversation-order.ts b/src/lib/chat/conversation-order.ts new file mode 100644 index 00000000..825cd915 --- /dev/null +++ b/src/lib/chat/conversation-order.ts @@ -0,0 +1,137 @@ +/** + * The sidebar's order (#18): how a list of conversations is sorted and grouped for display. + * + * Kept out of the sidebar component so the rules can be pinned without a browser. Three of + * them are easy to lose in a re-sort: + * + * - **Pinned chats sit on top, in their own group, newest pin first.** Pinning is a + * deliberate "keep this handy", so the order is when it was pinned, not when the chat + * last had activity. + * - **The archive is ordered by when each chat was archived**, newest first, and grouped + * by that day. A chat archived today that last had activity a month ago belongs under + * "Today" in the archive: that is when it was put away, and what someone looking for it + * remembers. + * - **The normal list is ordered by last activity** (`updatedAt`). Pinning and archiving + * never move it (see `$lib/chat/conversation-lifecycle.server`). + * + * "Sort by Name" or "Project" is an explicit choice and applies inside every group, the + * pinned one included. + * + * Imports nothing that needs SvelteKit, so specs can load it in the plain Playwright loader. + */ + +import { dayKey, dayLabel } from '../util/relative-time' + +export type ConversationSort = 'Recency' | 'Name' | 'Project' +export type ConversationGrouping = 'Project' | 'Status' | 'Environment' | 'Date' | 'None' +/** The normal list (pinned and recent chats) or the archive. */ +export type ConversationListView = 'chats' | 'archive' + +export type OrderableConversation = { + id: string + title: string + category: string | null + updatedAt: Date | string + pinnedAt: Date | string | null + archivedAt: Date | string | null +} + +export type ConversationGroup<T> = { key: string; label: string; items: T[] } + +const UNCATEGORIZED = 'Uncategorized' + +function epoch(value: Date | string | null | undefined): number { + if (!value) return 0 + const time = new Date(value).getTime() + return Number.isNaN(time) ? 0 : time +} + +/** + * The time a row is ordered, grouped and labelled by: when it was archived in the archive, + * its last activity everywhere else. + */ +export function listTime(conversation: OrderableConversation, view: ConversationListView): Date | string { + return view === 'archive' ? (conversation.archivedAt ?? conversation.updatedAt) : conversation.updatedAt +} + +/** Newest first by `time`; a stable sort, so ties keep the order the server sent. */ +function newestFirst<T>(list: T[], time: (item: T) => number): T[] { + return list.sort((a, b) => time(b) - time(a)) +} + +/** Sort a copy of `list`. `Recency` means `listTime`, newest first. */ +export function sortConversations<T extends OrderableConversation>( + list: readonly T[], + sortBy: ConversationSort, + view: ConversationListView, +): T[] { + const copy = [...list] + if (sortBy === 'Name') return copy.sort((a, b) => a.title.localeCompare(b.title)) + if (sortBy === 'Project') { + return copy.sort((a, b) => (a.category ?? UNCATEGORIZED).localeCompare(b.category ?? UNCATEGORIZED)) + } + return newestFirst(copy, (c) => epoch(listTime(c, view))) +} + +/** + * Sort `list` and split it into the groups the sidebar shows, top to bottom. Empty groups are + * left out. + * + * Group keys are stable and unique — the day (`day:2026-09-23`) or the category rather than + * the label, because day labels drop the year and the same date a year apart would otherwise + * collide. + */ +export function groupConversations<T extends OrderableConversation>( + list: readonly T[], + options: { sortBy: ConversationSort; groupBy: ConversationGrouping; view: ConversationListView }, +): ConversationGroup<T>[] { + const { sortBy, groupBy, view } = options + const sorted = sortConversations(list, sortBy, view) + + // The archive has no pinned group: archiving unpins. + const pinned = view === 'archive' ? [] : sorted.filter((c) => c.pinnedAt) + if (sortBy === 'Recency') newestFirst(pinned, (c) => epoch(c.pinnedAt)) + const rest = pinned.length > 0 ? sorted.filter((c) => !c.pinnedAt) : sorted + + const groups = bucket(rest, groupBy, view, pinned.length > 0).filter((group) => group.items.length > 0) + return pinned.length > 0 ? [{ key: 'pinned', label: 'Pinned', items: pinned }, ...groups] : groups +} + +function bucket<T extends OrderableConversation>( + list: T[], + groupBy: ConversationGrouping, + view: ConversationListView, + belowPinned: boolean, +): ConversationGroup<T>[] { + // Ungrouped, the list needs a heading only to set it apart from the pinned group. + if (groupBy === 'None') return [{ key: 'group:flat', label: belowPinned ? 'Recent' : '', items: list }] + + if (groupBy === 'Date') { + const days = new Map<string, { label: string; start: number; items: T[] }>() + for (const c of list) { + const at = listTime(c, view) + const key = dayKey(at) + const existing = days.get(key) + if (existing) { + existing.items.push(c) + continue + } + const start = new Date(at) + start.setHours(0, 0, 0, 0) + days.set(key, { label: dayLabel(at), start: start.getTime(), items: [c] }) + } + return [...days.entries()] + .sort(([, a], [, b]) => b.start - a.start) + .map(([key, day]) => ({ key: `day:${key}`, label: day.label, items: day.items })) + } + + // Project, Status and Environment all group by the category today. + const categories = new Map<string, T[]>() + for (const c of list) { + const key = c.category ?? UNCATEGORIZED + const items = categories.get(key) + if (items) items.push(c) + else categories.set(key, [c]) + } + return [...categories.entries()].map(([key, items]) => ({ key: `category:${key}`, label: key, items })) +} diff --git a/src/lib/chat/conversation-search.ts b/src/lib/chat/conversation-search.ts new file mode 100644 index 00000000..27f82f4d --- /dev/null +++ b/src/lib/chat/conversation-search.ts @@ -0,0 +1,100 @@ +/** + * Conversation search (#18) — the pieces shared by the server query and the sidebar. + * + * Imports nothing, so the browser can use it and specs can load it in the plain Playwright + * loader. + */ + +/** Shortest and longest search the sidebar sends. One character matches everything. */ +export const SEARCH_QUERY_MIN_CHARS = 2 +export const SEARCH_QUERY_MAX_CHARS = 200 + +/** + * The snippet's highlight markers. Control characters rather than `<mark>` so the snippet is + * plain text end to end: it is built from user and agent content, and the sidebar turns the + * markers into `<mark>` elements itself instead of rendering HTML from the database. The + * indexed text has these stripped (`buildMessageSearchText`), so a message cannot forge one. + */ +export const SNIPPET_START = '\u0002' +export const SNIPPET_STOP = '\u0003' + +export type SnippetPart = { text: string; mark: boolean } + +/** Split a highlighted snippet into plain and marked runs, for rendering as text nodes. */ +export function splitSnippet(snippet: string): SnippetPart[] { + const parts: SnippetPart[] = [] + let mark = false + let buffer = '' + const flush = () => { + if (buffer) parts.push({ text: buffer, mark }) + buffer = '' + } + for (const char of snippet) { + if (char === SNIPPET_START) { + flush() + mark = true + } else if (char === SNIPPET_STOP) { + flush() + mark = false + } else { + buffer += char + } + } + flush() + return parts +} + +/** Whitespace collapsed, control characters dropped. */ +export function normalizeSearchInput(raw: string): string { + return raw + .replace(/[\u0000-\u001f\u007f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +export type SearchQueryParts = { + /** The query as typed, minus a trailing prefix term — for `websearch_to_tsquery`. */ + exact: string | null + /** + * The same, with each path-shaped term (`engine/options.server.ts`) rewritten as a quoted + * phrase of its segments (`"engine options server ts"`). The indexed text spells every + * path out that way, so this is what finds a partial path. Null when nothing changed. + */ + segmented: string | null + /** The last word as a prefix match (`deplo:*`), so results appear while typing. */ + prefix: string | null +} + +const PLAIN_WORD = /^[\p{L}\p{N}]{2,}$/u +const PATH_SHAPED = /[\p{L}\p{N}][^\p{L}\p{N}\s]+[\p{L}\p{N}]/u +const WEBSEARCH_OPERATORS = new Set(['or', 'and', 'not']) + +/** + * How a typed search becomes a Postgres text query: `(exact OR segmented) AND prefix`, each + * part optional. See `searchConversations` in ./message-search.server for the SQL. + */ +export function searchQueryParts(raw: string): SearchQueryParts { + const cleaned = normalizeSearchInput(raw) + if (!cleaned) return { exact: null, segmented: null, prefix: null } + + const terms = cleaned.split(' ') + const last = terms[terms.length - 1] + const balancedQuotes = (cleaned.match(/"/g) ?? []).length % 2 === 0 + const usesOr = terms.slice(0, -1).some((term) => term.toLowerCase() === 'or') + + let prefix: string | null = null + if (balancedQuotes && !usesOr && PLAIN_WORD.test(last) && !WEBSEARCH_OPERATORS.has(last.toLowerCase())) { + prefix = `${last.toLowerCase()}:*` + terms.pop() + } + + const exact = terms.join(' ') || null + const segmentedTerms = terms.map((term) => { + if (term.includes('"') || term.startsWith('-') || !PATH_SHAPED.test(term)) return term + const segments = term.split(/[^\p{L}\p{N}]+/u).filter(Boolean) + return segments.length > 1 ? `"${segments.join(' ')}"` : term + }) + const segmented = segmentedTerms.join(' ') || null + + return { exact, segmented: segmented !== exact ? segmented : null, prefix } +} diff --git a/src/lib/chat/insert-message.server.ts b/src/lib/chat/insert-message.server.ts index fc3e989d..983bfba0 100644 --- a/src/lib/chat/insert-message.server.ts +++ b/src/lib/chat/insert-message.server.ts @@ -2,6 +2,7 @@ import { eq, sql } from 'drizzle-orm' import { db } from '$lib/db.server' import { getPostgresErrorCode } from '$lib/db/migrations.server' import { messages } from '$lib/sessions/sessions.schema' +import { scheduleMessageIndex } from '$lib/chat/message-search.server' type Db = typeof db type Tx = Parameters<Parameters<Db['transaction']>[0]>[0] @@ -26,6 +27,10 @@ const MAX_SEQUENCE_RETRIES = 5 * savepoint the retry's `MAX(sequence)` lookup failed with 25P02 ("current transaction is * aborted") instead — the final assistant reply of a turn was lost whenever a Stop's * partial save or a detached run wrote to the conversation at the same moment. + * + * On the db handle the row is committed on return, so it is handed to conversation search + * (#18) in the background. Inside a caller's transaction it is not committed yet; that + * caller indexes it after its own commit, or the boot backfill does. */ export async function insertMessageWithSequence( values: InsertValues, @@ -35,7 +40,7 @@ export async function insertMessageWithSequence( while (true) { attempt += 1 try { - return await executor.transaction(async (attemptTx) => { + const row = await executor.transaction(async (attemptTx) => { const [{ next }] = await attemptTx .select({ next: sql<number>`COALESCE(MAX(${messages.sequence}), 0) + 1` }) .from(messages) @@ -48,6 +53,8 @@ export async function insertMessageWithSequence( return row }) + if (executor === db) scheduleMessageIndex(row) + return row } catch (err) { // Postgres unique-violation = SQLSTATE 23505. Drizzle wraps the driver's error, so // the code is read through `cause`. Retry with a fresh max+1 lookup; cap the diff --git a/src/lib/chat/message-branch.server.ts b/src/lib/chat/message-branch.server.ts index d698ba64..9c0cf16a 100644 --- a/src/lib/chat/message-branch.server.ts +++ b/src/lib/chat/message-branch.server.ts @@ -15,6 +15,7 @@ import { and, eq, gt } from 'drizzle-orm' import { db } from '$lib/db.server' import { conversations, messages } from '$lib/sessions/sessions.schema' import { findLiveChatRun } from '$lib/runs/live-chat-run.server' +import { scheduleMessageIndex } from '$lib/chat/message-search.server' import { applyMessageRewind, isConversationRewinding, type RewindDeps } from './rewind.server' import { markCutPending } from './turn-plan.server' @@ -107,10 +108,18 @@ export async function editUserMessage( const prepared = await prepareBranch(input, target.conversationId, deps) if ('error' in prepared) return prepared.error - await db.transaction(async (tx) => { - await tx.update(messages).set({ content: input.content, metadata: markCutPending() }).where(eq(messages.id, target.id)) + const edited = await db.transaction(async (tx) => { + const [row] = await tx + .update(messages) + .set({ content: input.content, metadata: markCutPending() }) + .where(eq(messages.id, target.id)) + .returning() await deleteAfter(tx, target.conversationId, target.sequence) + return row }) + // #18 — committed now, so search finds the message by what it says now. The rows the cut + // dropped took their search rows with them (cascade). + scheduleMessageIndex(edited) return { success: true, conversationId: target.conversationId, ...prepared } } diff --git a/src/lib/chat/message-search-sql.ts b/src/lib/chat/message-search-sql.ts new file mode 100644 index 00000000..35a945b2 --- /dev/null +++ b/src/lib/chat/message-search-sql.ts @@ -0,0 +1,183 @@ +/** + * The SQL behind conversation search (#18), kept apart from the database client so it can be + * rendered and checked without one. ./message-search.server runs it. + */ + +import { sql, type SQL } from 'drizzle-orm' +import { conversations, messages, messageSearch } from '$lib/sessions/sessions.schema' +import { SEARCH_BUILDER_VERSION } from '$lib/chat/message-search-text' +import { searchQueryParts, SNIPPET_START, SNIPPET_STOP } from '$lib/chat/conversation-search' + +/** + * Tool blocks as the backfill reads them: what the builder uses and nothing else. A tool's + * raw result and a `Write`'s file body can each be megabytes, and a batch of messages is + * held in memory at once, so the trimming happens in Postgres before anything is sent. + */ +function trimmedWorkItems(column: SQL): SQL { + return sql`( + select coalesce(jsonb_agg( + case + when e.item->>'kind' = 'subagent' then jsonb_build_object( + 'kind', 'subagent', + 'agentName', e.item->'agentName', + 'task', to_jsonb(left(e.item->>'task', 2000)), + 'content', to_jsonb(left(e.item->>'content', 2000)) + ) + else jsonb_build_object( + 'kind', 'tool', + 'name', e.item->'name', + 'details', case when jsonb_typeof(e.item->'details') = 'object' + then (e.item->'details') - 'stdout' - 'stderr' - 'hunks' + else null end, + 'arguments', case when jsonb_typeof(e.item->'arguments') = 'object' + then (e.item->'arguments') - 'content' - 'old_string' - 'new_string' - 'new_source' - 'edits' + else to_jsonb(left(e.item->>'arguments', 20000)) end, + 'result', to_jsonb(left(coalesce(e.item->>'result', ''), 4000) || ' ' || right(coalesce(e.item->>'result', ''), 2000)) + ) + end + order by e.ord + ) filter ( + where jsonb_typeof(e.item) = 'object' + and coalesce(e.item->>'kind', 'tool') not in ('text', 'thinking', 'notice') + ), '[]'::jsonb) + from jsonb_array_elements(case when jsonb_typeof(${column}) = 'array' then ${column} else '[]'::jsonb end) + with ordinality as e(item, ord) + )` +} + +export type BackfillRow = { + id: string + conversation_id: string + role: string + content: string + attachments: unknown + blocks: unknown + tool_calls: unknown +} + +/** + * The next `batchSize` messages after `cursor` (by id) that have no search row, or one built + * by an older `SEARCH_BUILDER_VERSION`. Only one conversation's when `conversationId` is given. + */ +export function backfillBatchQuery(cursor: string | null, batchSize: number, conversationId?: string): SQL { + const scope: SQL = sql.join( + [ + cursor ? sql`and m.id > ${cursor}::uuid` : sql``, + conversationId ? sql`and m.conversation_id = ${conversationId}::uuid` : sql``, + ], + sql` `, + ) + return sql` + select + m.id, + m.conversation_id, + m.role, + left(m.content, 50000) as content, + m.attachments, + ${trimmedWorkItems(sql`m.metadata->'blocks'`)} as blocks, + ${trimmedWorkItems(sql`m.tool_calls`)} as tool_calls + from ${messages} m + left join ${messageSearch} ms on ms.message_id = m.id + where (ms.message_id is null or ms.builder_version < ${SEARCH_BUILDER_VERSION}) ${scope} + order by m.id + limit ${batchSize} + ` +} + +/** + * A message this short is its own snippet: the whole of it, with the matches marked. It fits + * in the three lines the sidebar shows. + */ +export const SNIPPET_WHOLE_BODY_MAX_CHARS = 100 + +/** `ts_headline` options for a message short enough to show whole. */ +export const WHOLE_BODY_HEADLINE_OPTIONS = `StartSel="${SNIPPET_START}", StopSel="${SNIPPET_STOP}", HighlightAll=true` + +/** + * `ts_headline` options for a longer message: up to two excerpts of about 18 words, each + * centred on a match. + * + * `ShortWord=0` matters. By default Postgres will not start or end an excerpt on a word of + * three letters or fewer, and trims them off the edges — which, when the excerpt reaches the + * start or end of the message, can trim everything but the match itself: "fix the login + * bug now" came back as just "login". Short messages are shown whole instead (above), and + * this keeps the edges of longer ones. + */ +export const HEADLINE_OPTIONS = `StartSel="${SNIPPET_START}", StopSel="${SNIPPET_STOP}", MaxFragments=2, MaxWords=18, MinWords=6, ShortWord=0, FragmentDelimiter=" … "` + +/** The highlighted snippet for `body` — see the two option sets above. */ +export function snippetSql(body: SQL, tsq: SQL): SQL { + return sql`case + when length(${body}) <= ${SNIPPET_WHOLE_BODY_MAX_CHARS} + then ts_headline('english', ${body}, ${tsq}, ${WHOLE_BODY_HEADLINE_OPTIONS}) + else ts_headline('english', ${body}, ${tsq}, ${HEADLINE_OPTIONS}) + end` +} + +/** The Postgres text query for a search: `(exact OR segmented) AND prefix`. Null when empty. */ +export function textQuery(raw: string): SQL | null { + const parts = searchQueryParts(raw) + const alternatives: SQL[] = [] + if (parts.exact) alternatives.push(sql`websearch_to_tsquery('english', ${parts.exact})`) + if (parts.segmented) alternatives.push(sql`websearch_to_tsquery('english', ${parts.segmented})`) + let query: SQL | null = alternatives.length > 0 ? sql`(${sql.join(alternatives, sql` || `)})` : null + if (parts.prefix) { + const prefix = sql`to_tsquery('english', ${parts.prefix})` + query = query ? sql`(${query} && ${prefix})` : prefix + } + return query +} + +export type ContentSearchRow = { + conversation_id: string + message_id: string + rank: number + title: string + updated_at: Date | string + pinned_at: Date | string | null + archived_at: Date | string | null + role: string + message_created_at: Date | string + snippet: string +} + +/** + * The best-matching message per conversation for one user, top `limit` conversations by rank, + * with a highlighted snippet. `ts_headline` is the expensive part, so it runs only for those. + */ +export function contentSearchQuery(input: { userId: string; tsq: SQL; includeArchived: boolean; limit: number }): SQL { + const { userId, tsq, includeArchived, limit } = input + return sql` + with best as ( + select distinct on (ms.conversation_id) + ms.conversation_id, + ms.message_id, + ts_rank_cd(ms.tsv, ${tsq}) as rank + from ${messageSearch} ms + join ${conversations} c on c.id = ms.conversation_id + where ms.tsv @@ ${tsq} + and c.user_id = ${userId} + ${includeArchived ? sql`` : sql`and c.archived_at is null`} + order by ms.conversation_id, rank desc, ms.message_id + ), + top as ( + select * from best order by rank desc limit ${limit} + ) + select + top.conversation_id, + top.message_id, + top.rank, + c.title, + c.updated_at, + c.pinned_at, + c.archived_at, + m.role, + m.created_at as message_created_at, + ${snippetSql(sql`ms.body`, tsq)} as snippet + from top + join ${messageSearch} ms on ms.message_id = top.message_id + join ${conversations} c on c.id = top.conversation_id + join ${messages} m on m.id = top.message_id + order by top.rank desc, c.updated_at desc + ` +} diff --git a/src/lib/chat/message-search-text.ts b/src/lib/chat/message-search-text.ts new file mode 100644 index 00000000..1e0bf726 --- /dev/null +++ b/src/lib/chat/message-search-text.ts @@ -0,0 +1,307 @@ +/** + * The text a message is found by in conversation search (#18). + * + * Search has to find *work*, not just prose. "The run where it touched options.server.ts" or + * "the one where it opened that PR" lives in a tool call — a file path in `Edit`'s arguments, + * a command in `Bash`'s, a URL in `gh pr create`'s output — not in anything the model said. + * So besides the message text, each tool call contributes one readable line: its name, the + * paths, commands and short arguments it was given, and any links in what it returned. + * + * What is deliberately left out: + * - raw tool output. A `Read` or a `Write` result can be megabytes; indexing it would make + * every search match every file the agent ever opened, and Postgres refuses a tsvector + * over ~1MB outright. Only the links in it are kept. + * - file bodies and edit payloads (`Write`'s `content`, `Edit`'s `old_string` / + * `new_string`). What a file says belongs to the file; the path is what finds the turn. + * - thinking and run notices. Neither is something a person searches their history for. + * - system messages (agent-switch anchors). They are instructions to the model. + * + * Paths get special treatment. Postgres parses `src/lib/engine/options.server.ts` as one + * `file` token and `options.server.ts` as one `host` token, so without help a search for + * `options` or `engine/options.server.ts` would miss the very turn that edited it. Every path + * is therefore followed by its basename and its segments as plain words, and the query side + * (`searchQueryParts` in ./conversation-search) turns a path-shaped search into the matching + * phrase of segments. + * + * The output is capped (`SEARCH_TEXT_MAX_CHARS`), so an insert can never fail on the + * tsvector size limit however large the message is. + * + * Pure and dependency-free: used by the server-side index writer and by specs running in the + * plain Playwright loader. + */ + +/** + * Bump when what this builder emits changes. The boot backfill rewrites every indexed row + * whose `builder_version` is older, so the whole history picks up the new rules. + * + * 2 — a delegated child's own tool calls (#32's card transcript), and its report when it + * said nothing on the way. + */ +export const SEARCH_BUILDER_VERSION = 2 + +/** Hard cap on the built text. Far below Postgres's ~1MB tsvector limit. */ +export const SEARCH_TEXT_MAX_CHARS = 60_000 + +/** How much of the message text itself is indexed. The rest of the budget is for tool lines. */ +const CONTENT_MAX_CHARS = 40_000 + +/** Cap for an argument that is always indexed. */ +const ARGUMENT_MAX_CHARS = 2_000 + +/** Any other string argument is indexed only when it is at most this long (titles, names, ids). */ +const LOOSE_ARGUMENT_MAX_CHARS = 300 + +const SUBAGENT_MAX_CHARS = 2_000 +/** Distinct tool calls of a delegated child that are indexed (#32). */ +const SUBAGENT_MAX_TOOL_CALLS = 50 +const TODO_MAX_CHARS = 2_000 + +/** A tool result is scanned for links only — its head and its tail, where `gh` prints the URL. */ +const RESULT_HEAD_CHARS = 4_000 +const RESULT_TAIL_CHARS = 2_000 +const MAX_RESULT_URLS = 20 + +/** Path-like tokens in prose that get their segments spelled out. */ +const MAX_TEXT_PATHS = 100 + +/** Arguments that say what a call was about, indexed whatever their length (capped). */ +const INDEXED_ARGUMENTS = new Set([ + 'file_path', + 'path', + 'notebook_path', + 'command', + 'description', + 'pattern', + 'glob', + 'url', + 'query', + 'prompt', + 'subagent_type', + 'skill', +]) + +/** Arguments never indexed: file bodies and edit payloads. */ +const EXCLUDED_ARGUMENTS = new Set(['content', 'old_string', 'new_string', 'new_source', 'edits']) + +/** Arguments whose value is a path, and so gets its segments spelled out. */ +const PATH_ARGUMENTS = new Set(['file_path', 'path', 'notebook_path']) + +// Includes the two markers the search snippet is highlighted with (./conversation-search). +// Stripped from the indexed text so a message can never forge a highlight. +const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g + +const URL_PATTERN = /https?:\/\/[^\s"'<>`()[\]{}]+/g + +/** A token with a `/`, `\` or `.` between word characters: a path, a file name, a host. */ +const PATH_LIKE_PATTERN = /[\p{L}\p{N}_~@-]+(?:[\\/.][\p{L}\p{N}_~@-]+)+/gu + +export type SearchableAttachment = { filename?: unknown } + +export type SearchableMessage = { + role: string + content: string + metadata?: unknown + attachments?: unknown + /** The pre-blocks shape of tool calls. Only read when `metadata.blocks` is absent. */ + toolCalls?: unknown +} + +function asRecord(value: unknown): Record<string, unknown> | null { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +function clip(value: string, max: number): string { + return value.length > max ? value.slice(0, max) : value +} + +/** + * `src/lib/engine/options.server.ts` → the path itself, its basename, and its segments as + * words: `… options.server.ts src lib engine options server ts`. + */ +export function pathSearchTerms(path: string): string { + const trimmed = path.trim() + if (!trimmed) return '' + const basename = trimmed.split(/[\\/]+/).filter(Boolean).pop() ?? trimmed + const segments = trimmed.split(/[^\p{L}\p{N}]+/u).filter(Boolean) + const parts = [trimmed] + if (basename !== trimmed) parts.push(basename) + if (segments.length > 1) parts.push(segments.join(' ')) + return parts.join(' ') +} + +/** Segments for every path-like token in free text (a command, a message), de-duplicated. */ +function pathTermsIn(text: string, limit: number): string[] { + const seen = new Set<string>() + const out: string[] = [] + for (const match of text.matchAll(PATH_LIKE_PATTERN)) { + const token = match[0] + if (seen.has(token) || !/\p{L}/u.test(token)) continue + seen.add(token) + const segments = token.split(/[^\p{L}\p{N}]+/u).filter(Boolean) + if (segments.length > 1) out.push(segments.join(' ')) + if (out.length >= limit) break + } + return out +} + +function urlsIn(text: string): string[] { + const scanned = + text.length > RESULT_HEAD_CHARS + RESULT_TAIL_CHARS + ? `${text.slice(0, RESULT_HEAD_CHARS)} ${text.slice(-RESULT_TAIL_CHARS)}` + : text + const urls = new Set<string>() + for (const match of scanned.matchAll(URL_PATTERN)) { + urls.add(match[0].replace(/[.,;:!?]+$/, '')) + if (urls.size >= MAX_RESULT_URLS) break + } + return [...urls] +} + +function parseArguments(value: unknown): Record<string, unknown> | null { + const record = asRecord(value) + if (record) return record + if (typeof value !== 'string' || !value.trim().startsWith('{')) return null + try { + return asRecord(JSON.parse(value)) + } catch { + return null + } +} + +function resultText(value: unknown): string { + if (typeof value === 'string') return value + if (value === null || value === undefined) return '' + try { + return JSON.stringify(value) + } catch { + return '' + } +} + +/** One readable line for a tool call: `Edit: src/lib/x.ts … | Bash: bun run check …`. */ +function toolLine(item: Record<string, unknown>): string { + const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : 'tool' + const parts: string[] = [] + const paths: string[] = [] + + const details = asRecord(item.details) + if (details) { + if (typeof details.path === 'string') paths.push(details.path) + if (typeof details.command === 'string') parts.push(clip(details.command, ARGUMENT_MAX_CHARS)) + if (typeof details.description === 'string') parts.push(clip(details.description, ARGUMENT_MAX_CHARS)) + if (details.kind === 'todo') { + const items = asArray(details.items) + .map((entry) => asRecord(entry)?.content) + .filter((text): text is string => typeof text === 'string') + if (items.length > 0) parts.push(clip(items.join('; '), TODO_MAX_CHARS)) + } + } + + const args = parseArguments(item.arguments) + if (args) { + for (const [key, value] of Object.entries(args)) { + if (typeof value !== 'string' || !value.trim() || EXCLUDED_ARGUMENTS.has(key)) continue + if (PATH_ARGUMENTS.has(key)) { + paths.push(value) + } else if (INDEXED_ARGUMENTS.has(key)) { + parts.push(clip(value, ARGUMENT_MAX_CHARS)) + } else if (value.length <= LOOSE_ARGUMENT_MAX_CHARS) { + parts.push(value) + } + } + } + + const urls = urlsIn(resultText(item.result)) + + const uniquePaths = [...new Set(paths.map((p) => clip(p.trim(), ARGUMENT_MAX_CHARS)).filter(Boolean))] + const text = [...uniquePaths.map(pathSearchTerms), ...new Set(parts), ...urls].join(' ') + // Commands and descriptions carry paths too (`git diff src/lib/x.ts`). + const extra = pathTermsIn([...parts, ...urls].join(' '), 20) + return [`${name}:`, text, ...extra].filter(Boolean).join(' ') +} + +/** + * A delegated child: what it was asked, what it said (or, when it said nothing on the way, + * its final report), and the tools it called with what each touched. + * + * Since #32 the delegation has no `tool` block of its own — the child's card is its only + * record — and the child's calls are kept on the card's `transcript`, each with a one-line + * label (a path, a command, a pattern). Those labels are the child's work, and a turn that + * delegated an edit has to be found by the file the child edited just as if the parent had + * edited it. + */ +function subagentLine(item: Record<string, unknown>): string { + const name = typeof item.agentName === 'string' ? item.agentName : 'subagent' + const task = typeof item.task === 'string' ? clip(item.task, SUBAGENT_MAX_CHARS) : '' + const said = typeof item.content === 'string' && item.content.trim() ? item.content : '' + const report = asRecord(item.details)?.report + const content = clip(said || (typeof report === 'string' ? report : ''), SUBAGENT_MAX_CHARS) + + const calls = new Set<string>() + for (const entry of asArray(item.transcript)) { + const call = asRecord(entry) + if (call?.kind !== 'tool' || typeof call.name !== 'string' || !call.name.trim()) continue + // A label past its cap ends in an ellipsis, which is not part of the path or command. + const label = typeof call.label === 'string' ? call.label.replace(/…$/, '').trim() : '' + calls.add(label ? `${call.name.trim()} ${label}` : call.name.trim()) + if (calls.size >= SUBAGENT_MAX_TOOL_CALLS) break + } + const work = clip([...calls].join(' '), SUBAGENT_MAX_CHARS) + // Paths in the labels get their segments spelled out, as a parent's commands do. + const extra = pathTermsIn(work, 20) + + return [`Subagent ${name}:`, task, content, work, ...extra].filter(Boolean).join(' ') +} + +/** Tool calls and subagent spans, in order, from `metadata.blocks` (or the older `toolCalls`). */ +function workLines(message: SearchableMessage): string[] { + const blocks = asArray(asRecord(message.metadata)?.blocks) + const items = blocks.length > 0 ? blocks : asArray(message.toolCalls) + const lines: string[] = [] + for (const entry of items) { + const item = asRecord(entry) + if (!item) continue + const kind = item.kind + // Text is already in `content`; thinking and notices are not what anyone searches for. + if (kind === 'text' || kind === 'thinking' || kind === 'notice') continue + if (kind === 'subagent') lines.push(subagentLine(item)) + else if (kind === 'tool' || typeof item.name === 'string') lines.push(toolLine(item)) + } + return lines +} + +function attachmentLine(attachments: unknown): string { + const names = asArray(attachments) + .map((entry) => asRecord(entry)?.filename) + .filter((name): name is string => typeof name === 'string' && name.trim().length > 0) + if (names.length === 0) return '' + return `Attachments: ${names.map((name) => pathSearchTerms(clip(name, ARGUMENT_MAX_CHARS))).join(' ')}` +} + +/** + * The text to index for one message. Empty for system messages; never longer than + * `SEARCH_TEXT_MAX_CHARS`. + */ +export function buildMessageSearchText(message: SearchableMessage): string { + if (message.role === 'system') return '' + + const content = clip(typeof message.content === 'string' ? message.content : '', CONTENT_MAX_CHARS) + const lines: string[] = [] + if (content.trim()) lines.push(content) + + const attachments = attachmentLine(message.attachments) + if (attachments) lines.push(attachments) + + lines.push(...workLines(message)) + + // Paths mentioned in the text itself ("I updated options.server.ts") get the same + // segment expansion, so a path-shaped search finds prose as well as tool calls. + const prosePaths = pathTermsIn(content, MAX_TEXT_PATHS) + if (prosePaths.length > 0) lines.push(prosePaths.join(' ')) + + return clip(lines.join('\n').replace(CONTROL_CHARACTERS, ' '), SEARCH_TEXT_MAX_CHARS) +} diff --git a/src/lib/chat/message-search.server.ts b/src/lib/chat/message-search.server.ts new file mode 100644 index 00000000..cbb38bf8 --- /dev/null +++ b/src/lib/chat/message-search.server.ts @@ -0,0 +1,275 @@ +/** + * Conversation search (#18): the index writer, its backfill, and the query. + * + * Every message gets one `message_search` row holding `buildMessageSearchText(message)`; + * Postgres generates the tsvector from it and a GIN index serves the search. See + * `messageSearch` in the sessions schema for why it is a side table, and + * ./message-search-text for what is indexed. + * + * Indexing is best-effort and happens after the message is committed. A message write never + * fails because of it, and nothing waits on it: the three places that write messages hand the + * row to `scheduleMessageIndex` and carry on. Anything missed — a failed write, a message + * from before this existed, a row built by an older version of the rules — is caught by + * `backfillMessageSearch`, which runs in the background at every boot. + */ + +import { and, desc, eq, ilike, isNull, sql } from 'drizzle-orm' +import { db } from '$lib/db.server' +import { getPostgresErrorCode } from '$lib/db/migrations.server' +import { conversations, messageSearch } from '$lib/sessions/sessions.schema' +import { logger } from '$lib/observability/logger' +import { buildMessageSearchText, SEARCH_BUILDER_VERSION } from '$lib/chat/message-search-text' +import { normalizeSearchInput, SEARCH_QUERY_MIN_CHARS, SEARCH_QUERY_MAX_CHARS } from '$lib/chat/conversation-search' +import { + backfillBatchQuery, + contentSearchQuery, + textQuery, + type BackfillRow, + type ContentSearchRow, +} from '$lib/chat/message-search-sql' + +type IndexableMessage = { + id: string + conversationId: string + role: string + content: string + metadata?: unknown + attachments?: unknown + toolCalls?: unknown +} + +function searchRow(message: IndexableMessage) { + return { + messageId: message.id, + conversationId: message.conversationId, + body: buildMessageSearchText(message), + builderVersion: SEARCH_BUILDER_VERSION, + updatedAt: new Date(), + } +} + +/** + * Write (or rewrite) the search rows for these messages. A message with nothing worth + * indexing — a system anchor — still gets a row with an empty body, so the backfill knows it + * has been seen and does not pick it up again on every boot. + */ +export async function indexMessages(rows: IndexableMessage[]): Promise<void> { + if (rows.length === 0) return + await db + .insert(messageSearch) + .values(rows.map(searchRow)) + .onConflictDoUpdate({ + target: messageSearch.messageId, + set: { + body: sql`excluded.body`, + builderVersion: sql`excluded.builder_version`, + updatedAt: sql`excluded.updated_at`, + }, + }) +} + +export function indexMessage(row: IndexableMessage): Promise<void> { + return indexMessages([row]) +} + +/** + * Index a message that has just been committed, in the background. Never throws and never + * delays the caller. A failure is logged and left to the boot backfill. + * + * Only for a committed row: inside an open transaction the foreign key to an uncommitted + * message fails, and a failed statement would abort the caller's transaction. + */ +export function scheduleMessageIndex(row: IndexableMessage | null | undefined): void { + if (!row) return + void indexMessage(row).catch((err) => { + logger.warn('[search] indexing a message failed; the boot backfill will retry it', { + messageId: row.id, + err, + }) + }) +} + +/** SQLSTATE for a foreign-key violation: here, a message (or its conversation) deleted meanwhile. */ +const FOREIGN_KEY_VIOLATION = '23503' + +/** + * `indexMessages` for rows read a moment ago, some of which may have been deleted since — + * a conversation deleted while the backfill was between its read and its write. One gone + * message fails the whole multi-row insert on its foreign key, so the batch is then retried + * a row at a time and the gone ones are skipped. Returns how many rows were written. + */ +export async function indexMessagesSkippingGone(rows: IndexableMessage[]): Promise<number> { + try { + await indexMessages(rows) + return rows.length + } catch (err) { + if (getPostgresErrorCode(err) !== FOREIGN_KEY_VIOLATION) throw err + } + + let written = 0 + for (const row of rows) { + try { + await indexMessage(row) + written += 1 + } catch (err) { + if (getPostgresErrorCode(err) !== FOREIGN_KEY_VIOLATION) throw err + } + } + return written +} + +/** + * Index every message that has no search row, or one built by an older + * `SEARCH_BUILDER_VERSION`. Walks `messages` by id in batches, so each batch starts where the + * last one stopped and the whole pass is one read of the table. Safe to run while messages + * are being written or deleted, and in several processes at once: each write is an upsert, + * and a message deleted mid-batch is skipped. + * + * `conversationId` limits the pass to one conversation; the boot backfill passes none. + */ +export async function backfillMessageSearch( + options: { batchSize?: number; maxBatches?: number; conversationId?: string } = {}, +): Promise<{ + indexed: number +}> { + const batchSize = Math.max(1, Math.min(options.batchSize ?? 200, 1000)) + const maxBatches = options.maxBatches ?? 100_000 + let cursor: string | null = null + let indexed = 0 + + for (let batch = 0; batch < maxBatches; batch += 1) { + const rows: BackfillRow[] = await db.execute<BackfillRow>( + backfillBatchQuery(cursor, batchSize, options.conversationId), + ) + if (rows.length === 0) break + + indexed += await indexMessagesSkippingGone( + rows.map((row) => ({ + id: row.id, + conversationId: row.conversation_id, + role: row.role, + content: row.content ?? '', + attachments: row.attachments, + metadata: { blocks: row.blocks }, + toolCalls: row.tool_calls, + })), + ) + cursor = rows[rows.length - 1].id + if (rows.length < batchSize) break + } + + return { indexed } +} + +export type ConversationSearchHit = { + conversationId: string + title: string + updatedAt: Date + pinned: boolean + archived: boolean + /** The title contains the search text. */ + titleMatch: boolean + /** The best-matching message, when one matched. */ + match: { + messageId: string + role: string + createdAt: Date + /** Plain text with the matched words between `SNIPPET_START` and `SNIPPET_STOP`. */ + snippet: string + } | null +} + +function escapeLike(value: string): string { + return value.replace(/[\\%_]/g, (char) => `\\${char}`) +} + +/** + * Search one user's conversations: message text and tool calls (full-text, ranked), plus + * titles (substring). One result per conversation, best first. Archived conversations only + * when asked for. Never another user's. + */ +export async function searchUserConversations( + userId: string, + rawQuery: string, + options: { includeArchived?: boolean; limit?: number } = {}, +): Promise<ConversationSearchHit[]> { + const q = normalizeSearchInput(rawQuery).slice(0, SEARCH_QUERY_MAX_CHARS) + if (q.length < SEARCH_QUERY_MIN_CHARS) return [] + const limit = Math.max(1, Math.min(options.limit ?? 30, 50)) + const includeArchived = options.includeArchived === true + const tsq = textQuery(q) + + const contentSearch = async () => { + if (!tsq) return [] + return db.transaction(async (tx) => { + // A query of only stop words ("the") makes Postgres raise a NOTICE per call, which + // the client logs as a warning. Expected here, so not worth a log line each time. + await tx.execute(sql`select set_config('client_min_messages', 'warning', true)`) + return tx.execute<ContentSearchRow>(contentSearchQuery({ userId, tsq, includeArchived, limit })) + }) + } + + const titleSearch = () => + db + .select({ + id: conversations.id, + title: conversations.title, + updatedAt: conversations.updatedAt, + pinnedAt: conversations.pinnedAt, + archivedAt: conversations.archivedAt, + }) + .from(conversations) + .where( + and( + eq(conversations.userId, userId), + ilike(conversations.title, `%${escapeLike(q)}%`), + includeArchived ? undefined : isNull(conversations.archivedAt), + ), + ) + .orderBy(desc(conversations.updatedAt)) + .limit(limit) + + const [contentRows, titleRows] = await Promise.all([contentSearch(), titleSearch()]) + + const hits = new Map<string, ConversationSearchHit & { score: number }>() + for (const row of contentRows) { + hits.set(row.conversation_id, { + conversationId: row.conversation_id, + title: row.title, + updatedAt: new Date(row.updated_at), + pinned: row.pinned_at !== null, + archived: row.archived_at !== null, + titleMatch: false, + match: { + messageId: row.message_id, + role: row.role, + createdAt: new Date(row.message_created_at), + snippet: row.snippet ?? '', + }, + score: Number(row.rank) || 0, + }) + } + for (const row of titleRows) { + const existing = hits.get(row.id) + if (existing) { + existing.titleMatch = true + existing.score += 1 + continue + } + hits.set(row.id, { + conversationId: row.id, + title: row.title, + updatedAt: row.updatedAt, + pinned: row.pinnedAt !== null, + archived: row.archivedAt !== null, + titleMatch: true, + match: null, + score: 1, + }) + } + + return [...hits.values()] + .sort((a, b) => b.score - a.score || b.updatedAt.getTime() - a.updatedAt.getTime()) + .slice(0, limit) + .map(({ score: _score, ...hit }) => hit) +} diff --git a/src/lib/chat/stream-persistence.server.ts b/src/lib/chat/stream-persistence.server.ts index 844185c9..cf67b8cc 100644 --- a/src/lib/chat/stream-persistence.server.ts +++ b/src/lib/chat/stream-persistence.server.ts @@ -16,6 +16,8 @@ import { and, desc, eq, sql } from 'drizzle-orm' import { db } from '$lib/db.server' import { conversations, messages } from '$lib/sessions/sessions.schema' import { insertMessageWithSequence } from '$lib/chat/insert-message.server' +import { unarchiveOnUserMessage } from '$lib/chat/conversation-lifecycle.server' +import { scheduleMessageIndex } from '$lib/chat/message-search.server' import { generateTitle } from '$lib/chat/chat.server' import { logger } from '$lib/observability/logger' import type { getSettings } from '$lib/settings' @@ -44,6 +46,8 @@ export async function resolveParentMessage(input: { toolCalls: [], attachments: input.body.attachments ?? [], }) + // #18 — writing in an archived chat brings it back to the list. + await unarchiveOnUserMessage(input.conversationId) return { ok: true, parentMessageId: createdUser.id } } @@ -87,7 +91,7 @@ export type AssistantPersistInput = { * means a crash between them can't leave the row + totals desynced. */ export async function persistAssistantMessage(input: AssistantPersistInput) { - return db.transaction(async (tx) => { + const persisted = await db.transaction(async (tx) => { const [existingPartial] = await tx .select({ id: messages.id }) .from(messages) @@ -155,6 +159,9 @@ export async function persistAssistantMessage(input: AssistantPersistInput) { return written }) + // #18 — committed now, so conversation search can index it (in the background). + scheduleMessageIndex(persisted) + return persisted } /** diff --git a/src/lib/db/bootstrap.server.ts b/src/lib/db/bootstrap.server.ts index 07b28a0e..9f3ffdb5 100644 --- a/src/lib/db/bootstrap.server.ts +++ b/src/lib/db/bootstrap.server.ts @@ -434,9 +434,11 @@ async function startWorkerAndScheduler(generation: number): Promise<void> { } /** - * Skill-embedding backfill — best-effort, runs once at boot. Non-blocking; the - * logger's relevance filter falls back to listing every skill if embeddings - * aren't ready yet. + * Background backfills — best-effort, run once at boot, never block it. + * + * Skill embeddings: the logger's relevance filter falls back to listing every + * skill if embeddings aren't ready yet. Conversation search: messages without a + * search row are indexed; search simply finds less until it finishes. */ function kickoffBackgroundBackfills(): void { void (async () => { @@ -450,4 +452,18 @@ function kickoffBackgroundBackfills(): void { console.warn('[db] Skill embedding backfill failed (non-fatal):', err) } })() + // #18 — index messages for conversation search that are not indexed yet (history from + // before search existed, a write whose indexing failed) or were indexed by an older + // version of the rules. Batched, in the background; search works on whatever is done. + void (async () => { + try { + const { backfillMessageSearch } = await import('$lib/chat/message-search.server') + const result = await backfillMessageSearch() + if (result.indexed > 0) { + console.log(`[db] Indexed ${result.indexed} message(s) for conversation search`) + } + } catch (err) { + console.warn('[db] Conversation search backfill failed (non-fatal):', err) + } + })() } diff --git a/src/lib/sessions/sessions.schema.ts b/src/lib/sessions/sessions.schema.ts index 9c134a25..582c1ac4 100644 --- a/src/lib/sessions/sessions.schema.ts +++ b/src/lib/sessions/sessions.schema.ts @@ -1,4 +1,17 @@ -import { integer, jsonb, numeric, pgEnum, pgTable, real, text, timestamp, uuid } from 'drizzle-orm/pg-core' +import { sql, type SQL } from 'drizzle-orm' +import { + customType, + index, + integer, + jsonb, + numeric, + pgEnum, + pgTable, + real, + text, + timestamp, + uuid, +} from 'drizzle-orm/pg-core' import { users } from '$lib/auth/auth.schema' import { agents } from '$lib/agents/agents.schema' import type { TodoItem } from '$lib/engine/tool-result-details' @@ -68,6 +81,14 @@ export const conversations = pgTable('conversations', { // #21 — the latest `TodoWrite` list for this conversation. Null until the agent writes // one, and cleared when the user dismisses it. See `ConversationTodoList` above. todoList: jsonb('todo_list').$type<ConversationTodoList | null>(), + // #18 — conversation lifecycle. Both are timestamps rather than booleans: set means "on", + // and the time is worth having — pinned chats list in the order they were pinned, and + // the archive shows when something was put away. Neither moves `updatedAt`, so pinning or + // archiving a chat never reorders it. Archiving clears the pin, pinning clears the + // archive, and a message the user sends brings an archived chat back (see + // `$lib/chat/conversation-lifecycle.server`). + pinnedAt: timestamp('pinned_at', { withTimezone: true }), + archivedAt: timestamp('archived_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }) @@ -101,3 +122,50 @@ export const messages = pgTable('messages', { // unique index serializes racing writers. sequence: integer('sequence').notNull(), }) + +/** + * Postgres's full-text document type. Drizzle has no built-in for it; this is the + * documented `customType` pattern. + */ +const tsvector = customType<{ data: string }>({ + dataType() { + return 'tsvector' + }, +}) + +/** + * #18 — the search index behind "search across conversations". + * + * One row per message, holding the text worth finding it by (`body`) and the parsed + * document Postgres searches (`tsv`, generated from `body` and GIN-indexed). `body` is + * built by `buildMessageSearchText`: the message text, attachment names and — the part + * that makes it find *work* rather than prose — each tool call's name, file paths, + * commands and short arguments. Raw tool output and file contents are left out. + * + * A side table rather than columns on `messages` because the chat page loads messages with + * `select()`, which would ship the search text to the browser with every conversation; and + * because it can be rebuilt: a change to what is indexed bumps `SEARCH_BUILDER_VERSION`, + * and the boot backfill rewrites every row whose `builderVersion` is older. + * + * Written after the message is committed, best-effort: indexing never fails a message + * write, and anything missed is picked up by the backfill (`$lib/chat/message-search.server`). + */ +export const messageSearch = pgTable( + 'message_search', + { + messageId: uuid('message_id') + .primaryKey() + .references(() => messages.id, { onDelete: 'cascade' }), + conversationId: uuid('conversation_id') + .notNull() + .references(() => conversations.id, { onDelete: 'cascade' }), + body: text('body').notNull(), + builderVersion: integer('builder_version').notNull(), + tsv: tsvector('tsv').generatedAlwaysAs((): SQL => sql`to_tsvector('english', ${messageSearch.body})`), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('message_search_tsv_idx').using('gin', table.tsv), + index('message_search_conversation_idx').on(table.conversationId), + ], +) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b67e5c12..c7ebfa0a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -7,6 +7,7 @@ import { onMount } from 'svelte'; import { handOffAttachments, type HandoffAttachment } from '$lib/chat/new-chat-handoff'; import { onConversationListChange } from '$lib/chat/conversation-list-sync'; + import { sortConversations } from '$lib/chat/conversation-order'; import { createConversation, getConversations, listAgentsForPicker, getWorkbenchPreferences } from '$lib/chat/chat.remote'; import { getSettings } from '$lib/settings'; import { fetchFresh } from '$lib/ui/fresh-query'; @@ -44,6 +45,8 @@ */ const conversationsQuery = browser ? getConversations() : null; const recentChats = $derived<Conversation[]>(conversationsQuery?.current ?? []); + /** Last activity first. The server lists every pinned chat ahead of the rest (#18); "Recent" is not about pins. */ + const recentByActivity = $derived(sortConversations(recentChats, 'Recency', 'chats')); let agentChoices = $state<AgentChoice[]>([]); let liveRuns = $state<Record<string, LiveRun>>({}); @@ -292,7 +295,7 @@ <div class="w-full space-y-2 text-left desktop:hidden"> <h2 class="text-xs font-semibold uppercase tracking-wide text-base-content/40">Recent chats</h2> <div class="space-y-0.5"> - {#each recentChats.slice(0, 5) as chat (chat.id)} + {#each recentByActivity.slice(0, 5) as chat (chat.id)} {@const run = runForConversation(chat)} <a href={`/chat/${chat.id}`} diff --git a/src/routes/chat/[id]/export/+server.ts b/src/routes/chat/[id]/export/+server.ts new file mode 100644 index 00000000..af070c4f --- /dev/null +++ b/src/routes/chat/[id]/export/+server.ts @@ -0,0 +1,43 @@ +import { error, type RequestHandler } from '@sveltejs/kit' +import { loadConversationForExport } from '$lib/chat/conversation-export.server' +import { + exportContentDisposition, + exportConversationJson, + exportConversationMarkdown, + exportFileNames, +} from '$lib/chat/conversation-export' + +/** + * #18 — download one conversation: `?format=md` (a readable transcript) or `?format=json` + * (everything, unshortened). + * + * Someone else's conversation answers 404, the same as one that does not exist, so a + * conversation id says nothing about whether it is real. The session check is here as well + * as in the hook, so this route stays private if the hook's public-path list ever widens. + */ +export const GET: RequestHandler = async ({ params, url, locals }) => { + if (!locals.user) throw error(401, 'Unauthorized') + + const format = url.searchParams.get('format') ?? 'md' + if (format !== 'md' && format !== 'json') throw error(400, 'format must be md or json') + + const data = await loadConversationForExport(locals.user.id, params.id ?? '') + if (!data) throw error(404, 'Conversation not found') + + const exportedAt = new Date() + const names = exportFileNames(data.conversation.title, exportedAt, format) + const body = + format === 'md' + ? exportConversationMarkdown({ ...data, exportedAt }) + : `${JSON.stringify(exportConversationJson({ ...data, exportedAt }), null, 2)}\n` + + return new Response(body, { + headers: { + 'Content-Type': format === 'md' ? 'text/markdown; charset=utf-8' : 'application/json; charset=utf-8', + 'Content-Disposition': exportContentDisposition(names), + // Private transcript: never in a shared cache, never reused for a later download. + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', + }, + }) +} diff --git a/tests/chat.background-commands.spec.ts b/tests/chat.background-commands.spec.ts index 7a469006..973e8b12 100644 --- a/tests/chat.background-commands.spec.ts +++ b/tests/chat.background-commands.spec.ts @@ -82,7 +82,7 @@ test.describe('deleting a conversation stops what it is running', () => { }) try { const { deleteConversationForUser } = await import('../src/lib/chat/conversation-delete.server') - expect(await deleteConversationForUser(userId, conv.id)).toEqual({ deleted: true, stoppedRun: true }) + expect(await deleteConversationForUser(userId, conv.id)).toEqual({ deleted: true, stoppedRuns: 1 }) expect(interrupted).toBe(1) // Stopped while the run could still be found by its conversation, then deleted. expect(existedWhenInterrupted).toBe(true) @@ -106,7 +106,7 @@ test.describe('deleting a conversation stops what it is running', () => { const release = registerRunHandle(finishedId, fakeHandle(() => interrupted++)) try { const { deleteConversationForUser } = await import('../src/lib/chat/conversation-delete.server') - expect(await deleteConversationForUser(userId, conv.id)).toEqual({ deleted: true, stoppedRun: false }) + expect(await deleteConversationForUser(userId, conv.id)).toEqual({ deleted: true, stoppedRuns: 0 }) expect(interrupted).toBe(0) } finally { release() @@ -126,7 +126,7 @@ test.describe('deleting a conversation stops what it is running', () => { const release = registerRunHandle(runId, fakeHandle(() => interrupted++)) try { const { deleteConversationForUser } = await import('../src/lib/chat/conversation-delete.server') - expect(await deleteConversationForUser(randomUUID(), conv.id)).toEqual({ deleted: false, stoppedRun: false }) + expect(await deleteConversationForUser(randomUUID(), conv.id)).toEqual({ deleted: false, stoppedRuns: 0 }) expect(interrupted).toBe(0) expect(await conversationExists(conv.id)).toBe(true) } finally { diff --git a/tests/chat.conversation-delete.spec.ts b/tests/chat.conversation-delete.spec.ts new file mode 100644 index 00000000..66505220 --- /dev/null +++ b/tests/chat.conversation-delete.spec.ts @@ -0,0 +1,188 @@ +import { randomUUID } from 'node:crypto' +import { expect, test } from '@playwright/test' +import { cleanupPrefixedRecords, getActiveUserId, getSql, seedConversation, uniquePrefix } from './helpers' +import { claimRun, registerRunHandle, type EngineQueryHandle } from '../src/lib/engine/run-registry.server' + +/** + * #18 — deleting a conversation while a turn is still running in it. + * + * The delete cascades to the conversation's `chat_runs` rows, and Stop finds a run through + * its row — so deleting under a live turn used to leave the agent running tools with nothing + * able to stop it. `deleteConversationForUser` now stops the turn first, the way Stop does + * (the run registry), waits a bounded time for it to wind down, and then deletes. Pinned: + * - the live turn is interrupted, and the delete waits for it to end before deleting + * - someone else's conversation is neither deleted nor has its turn touched: ownership is + * checked before anything is stopped + * - a turn slow to stop does not hold the delete up past the settle time + * - with no live turn it is an ordinary delete, and a finished run is left alone + * + * The registry is process-local, so the "turn" here is a fake handle registered in this + * worker — the same process the delete runs in. + */ + +async function seedRun(conversationId: string, userId: string, opts: { finished?: boolean } = {}) { + const sql = getSql() + const [run] = await sql<{ id: string }[]>` + insert into chat_runs (conversation_id, user_id, state, source, started_at, updated_at, finished_at) + values ( + ${conversationId}, + ${userId}, + ${opts.finished ? 'completed' : 'running'}, + 'chat_stream', + now(), + now(), + ${opts.finished ? sql`now()` : null} + ) + returning id + ` + return run.id +} + +async function conversationExists(id: string) { + const sql = getSql() + const [row] = await sql<{ id: string }[]>`select id from conversations where id = ${id}` + return Boolean(row) +} + +function fakeHandle(onInterrupt: () => void): EngineQueryHandle { + return { + interrupt: async () => onInterrupt(), + stopTask: async () => {}, + getContextUsage: async () => null, + } +} + +test.describe('deleting a conversation with a live turn', () => { + test('stops the turn first and deletes once it has wound down', async () => { + const prefix = uniquePrefix('conv-delete-live') + const userId = await getActiveUserId() + const { deleteConversationForUser } = await import('../src/lib/chat/conversation-delete.server') + + try { + const conversation = await seedConversation(prefix, { userId }) + const runId = await seedRun(conversation.id, userId) + const releaseClaim = claimRun(runId) + let interrupted = 0 + const turn: { endedAt: number | null } = { endedAt: null } + let releaseHandle = () => {} + releaseHandle = registerRunHandle( + runId, + fakeHandle(() => { + interrupted += 1 + // An interrupted turn saves its partial reply and then lets go of its claim. + setTimeout(() => { + releaseHandle() + releaseClaim() + turn.endedAt = Date.now() + }, 150) + }), + ) + + try { + const result = await deleteConversationForUser(userId, conversation.id, { settleMs: 5_000, pollMs: 10 }) + const deletedAt = Date.now() + + expect(result).toEqual({ deleted: true, stoppedRuns: 1 }) + expect(interrupted).toBe(1) + expect(turn.endedAt, 'the turn ended before the delete returned').not.toBeNull() + expect(turn.endedAt!).toBeLessThanOrEqual(deletedAt) + expect(await conversationExists(conversation.id)).toBe(false) + } finally { + releaseHandle() + releaseClaim() + } + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test("someone else's delete neither deletes the chat nor stops its turn", async () => { + const prefix = uniquePrefix('conv-delete-stranger') + const userId = await getActiveUserId() + const { deleteConversationForUser } = await import('../src/lib/chat/conversation-delete.server') + + try { + const conversation = await seedConversation(prefix, { userId }) + const runId = await seedRun(conversation.id, userId) + const releaseClaim = claimRun(runId) + let interrupted = 0 + const releaseHandle = registerRunHandle(runId, fakeHandle(() => (interrupted += 1))) + + try { + expect(await deleteConversationForUser(randomUUID(), conversation.id, { settleMs: 200, pollMs: 10 })).toEqual({ + deleted: false, + stoppedRuns: 0, + }) + expect(interrupted).toBe(0) + expect(await conversationExists(conversation.id)).toBe(true) + } finally { + releaseHandle() + releaseClaim() + } + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('a turn slow to stop does not hold the delete up past the settle time', async () => { + const prefix = uniquePrefix('conv-delete-slow') + const userId = await getActiveUserId() + const { deleteConversationForUser } = await import('../src/lib/chat/conversation-delete.server') + + try { + const conversation = await seedConversation(prefix, { userId }) + const runId = await seedRun(conversation.id, userId) + // Interrupted, but never lets go of its claim within the test. + const releaseClaim = claimRun(runId) + let interrupted = 0 + const releaseHandle = registerRunHandle(runId, fakeHandle(() => (interrupted += 1))) + + try { + const startedAt = Date.now() + const result = await deleteConversationForUser(userId, conversation.id, { settleMs: 300, pollMs: 10 }) + const elapsed = Date.now() - startedAt + + expect(result).toEqual({ deleted: true, stoppedRuns: 1 }) + expect(interrupted).toBe(1) + expect(elapsed).toBeGreaterThanOrEqual(300) + expect(elapsed).toBeLessThan(10_000) + expect(await conversationExists(conversation.id)).toBe(false) + } finally { + releaseHandle() + releaseClaim() + } + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('with no live turn it is an ordinary delete, and a finished run is left alone', async () => { + const prefix = uniquePrefix('conv-delete-idle') + const userId = await getActiveUserId() + const { deleteConversationForUser } = await import('../src/lib/chat/conversation-delete.server') + + try { + const conversation = await seedConversation(prefix, { userId }) + const finishedId = await seedRun(conversation.id, userId, { finished: true }) + let interrupted = 0 + // A handle still registered for a run whose row says it finished: not asked to stop. + const releaseHandle = registerRunHandle(finishedId, fakeHandle(() => (interrupted += 1))) + + try { + expect(await deleteConversationForUser(userId, conversation.id, { settleMs: 200, pollMs: 10 })).toEqual({ + deleted: true, + stoppedRuns: 0, + }) + expect(interrupted).toBe(0) + expect(await conversationExists(conversation.id)).toBe(false) + + // Deleting it again finds nothing. + expect(await deleteConversationForUser(userId, conversation.id)).toEqual({ deleted: false, stoppedRuns: 0 }) + } finally { + releaseHandle() + } + } finally { + await cleanupPrefixedRecords(prefix) + } + }) +}) diff --git a/tests/chat.conversation-export.spec.ts b/tests/chat.conversation-export.spec.ts new file mode 100644 index 00000000..f938d52a --- /dev/null +++ b/tests/chat.conversation-export.spec.ts @@ -0,0 +1,379 @@ +import { expect, test } from '@playwright/test' +import { + codeFence, + EXPORT_FORMAT, + exportContentDisposition, + exportConversationJson, + exportConversationMarkdown, + exportFileNames, + type ExportInput, + type ExportMessage, +} from '../src/lib/chat/conversation-export' +import { authenticateContext, cleanupPrefixedRecords, getActiveUserId, getSql, uniquePrefix } from './helpers' + +/** + * #18 — exporting one conversation. + * + * The first half is the formatter, pure: blocks in order, fences the content cannot close, + * long output shortened in Markdown with a pointer to the JSON, and JSON with everything but + * the owner's id. The second half is the download route: an attachment that is not cached, + * 400 for an unknown format, 404 for someone else's conversation, and refused without a + * session. + */ + +const at = new Date('2026-09-23T14:02:00Z') + +function message(overrides: Partial<ExportMessage>): ExportMessage { + return { + id: '00000000-0000-4000-8000-000000000001', + sequence: 1, + role: 'user', + content: '', + model: null, + parentMessageId: null, + createdAt: at, + tokensIn: 0, + tokensOut: 0, + cost: '0', + attachments: [], + metadata: {}, + toolCalls: [], + ...overrides, + } +} + +function input(messages: ExportMessage[]): ExportInput { + return { + conversation: { + id: '00000000-0000-4000-8000-0000000000aa', + title: 'Fix the login page', + category: null, + model: 'claude-sonnet-5', + agentId: null, + projectId: null, + permissionMode: 'default', + totalTokens: 1234, + totalCost: '0.0123', + pinnedAt: at, + archivedAt: null, + createdAt: at, + updatedAt: at, + }, + agent: { id: '00000000-0000-4000-8000-0000000000bb', name: 'Chat' }, + messages, + exportedAt: at, + } +} + +const EDIT_BLOCK = { + kind: 'tool', + name: 'Edit', + arguments: { file_path: 'src/routes/login/+page.svelte', old_string: 'a', new_string: 'b' }, + result: 'ok', + success: true, + executionMs: 3, + details: { + kind: 'file_edit', + tool: 'Edit', + path: 'src/routes/login/+page.svelte', + changeType: 'update', + hunks: [{ oldStart: 4, oldLines: 1, newStart: 4, newLines: 1, lines: ['-old line', '+new line'] }], + additions: 1, + deletions: 1, + unavailable: 'none', + truncated: false, + }, +} + +test.describe('conversation export — the formatter', () => { + test('Markdown has a header and each message with its blocks in order', () => { + const md = exportConversationMarkdown( + input([ + message({ sequence: 1, role: 'user', content: 'Why does login fail?' }), + message({ + id: '00000000-0000-4000-8000-000000000002', + sequence: 2, + role: 'assistant', + model: 'claude-sonnet-5', + content: 'Found it.', + metadata: { + blocks: [ + { kind: 'thinking', content: 'Check the form action.' }, + { kind: 'text', content: 'Found it.' }, + EDIT_BLOCK, + { + kind: 'tool', + name: 'Bash', + arguments: { command: 'bun run check' }, + result: '0 errors', + success: true, + executionMs: 900, + details: { kind: 'shell', tool: 'Bash', command: 'bun run check', description: 'Type-check', stdout: '0 errors', stderr: '', interrupted: false, backgroundTaskId: null, timedOutAfterMs: null, persistedOutputPath: null, truncated: false }, + }, + { kind: 'subagent', agentId: 't', agentName: 'Researcher', conversationId: null, task: 'read the docs', content: 'The docs say X.', success: true }, + { kind: 'notice', notice: { kind: 'compacted', level: 'info', title: 'Earlier messages were summarised', detail: null, persist: true } }, + ], + }, + }), + ]), + ) + + expect(md).toContain('# Fix the login page') + expect(md).toContain('- Exported: 2026-09-23 14:02 UTC') + expect(md).toContain('- Agent: Chat') + expect(md).toContain('- Tokens: 1,234 · Cost: $0.0123') + expect(md).toContain('- Status: Pinned') + expect(md).toContain('## You · 2026-09-23 14:02 UTC\n\nWhy does login fail?') + expect(md).toContain('## Assistant (claude-sonnet-5) · 2026-09-23 14:02 UTC') + expect(md).toContain('<details><summary>Thinking</summary>') + expect(md).toContain('**Tool · Edit** `src/routes/login/+page.svelte` (+1 −1) ✓') + expect(md).toContain('```diff\n@@ -4,1 +4,1 @@\n-old line\n+new line\n```') + expect(md).toContain('**Tool · Bash** ✓ — Type-check') + expect(md).toContain('```sh\n$ bun run check\n```') + expect(md).toContain('> **Subagent · Researcher** — read the docs') + expect(md).toContain('_Earlier messages were summarised_') + + // Blocks keep their order, and the text is not repeated from `content`. + const order = ['Thinking', 'Found it.', '**Tool · Edit**', '**Tool · Bash**', 'Subagent · Researcher', 'Earlier messages'] + const positions = order.map((needle) => md.indexOf(needle)) + expect(positions.every((p) => p > 0)).toBe(true) + expect([...positions].sort((a, b) => a - b)).toEqual(positions) + expect(md.split('Found it.').length - 1).toBe(1) + }) + + test("a delegated child's card lists its own calls, and a stopped child reads as stopped (#32)", () => { + const md = exportConversationMarkdown( + input([ + message({ + id: '00000000-0000-4000-8000-000000000002', + sequence: 2, + role: 'assistant', + content: '', + metadata: { + blocks: [ + { + kind: 'subagent', + agentId: 'a1', + agentName: 'Coder', + conversationId: null, + task: 'fix the stream route', + content: '', + success: true, + status: 'completed', + transcript: [ + { kind: 'tool', name: 'Read', label: 'src/routes/chat/[id]/stream/+server.ts', success: true }, + { kind: 'tool', name: 'Bash', label: 'bun run check', success: false }, + ], + details: { kind: 'subagent', tool: 'Agent', status: 'completed', report: 'Patched the retry path.' }, + }, + { + kind: 'subagent', + agentId: 'a2', + agentName: 'Researcher', + conversationId: null, + task: 'read the docs', + content: 'Halfway through', + success: false, + status: 'stopped', + transcript: [], + error: 'Stopped before it finished.', + }, + ], + }, + }), + ]), + ) + + expect(md).toContain('> **Subagent · Coder** — fix the stream route') + expect(md).toContain('> - Read `src/routes/chat/[id]/stream/+server.ts`') + expect(md).toContain('> - Bash `bun run check` ✗') + // Nothing said on the way, so the report is the body. + expect(md).toContain('> Patched the retry path.') + // Stopped is not failed: no cross, and the reason is kept. + expect(md).toContain('> **Subagent · Researcher** (stopped) — read the docs') + expect(md).toContain('> _Stopped before it finished._') + expect(md).not.toContain('**Subagent · Researcher** ✗') + }) + + test('a fence is longer than any run of backticks inside it', () => { + const inner = 'before\n````\nnested fence\n````\nafter' + const fenced = codeFence(inner, 'text') + expect(fenced.startsWith('`````text\n')).toBe(true) + expect(fenced.endsWith('\n`````')).toBe(true) + expect(codeFence('no ticks')).toBe('```\nno ticks\n```') + + // And through the formatter: tool output with a fence of its own cannot close ours. + const md = exportConversationMarkdown( + input([ + message({ + role: 'assistant', + metadata: { blocks: [{ kind: 'tool', name: 'Read', arguments: { file_path: 'README.md' }, result: 'x\n```\n# not a heading\n```', success: true, executionMs: 1 }] }, + }), + ]), + ) + expect(md).toContain('````text\nx\n```\n# not a heading\n```\n````') + }) + + test('long tool output is shortened in Markdown and kept whole in JSON', () => { + const long = 'line\n'.repeat(2000) + const data = input([ + message({ + role: 'assistant', + metadata: { blocks: [{ kind: 'tool', name: 'Read', arguments: { file_path: 'big.log' }, result: long, success: true, executionMs: 1 }] }, + }), + ]) + const md = exportConversationMarkdown(data) + expect(md).toContain('(truncated — see the JSON export for the full output)') + expect(md.length).toBeLessThan(long.length) + + const json = exportConversationJson(data) + const blocks = (json.messages[0].metadata as { blocks: Array<{ result: string }> }).blocks + expect(blocks[0].result).toBe(long) + }) + + test('JSON carries every message and block, and not the owner', () => { + const data = input([ + message({ role: 'user', content: 'hi', attachments: [{ id: 'a', filename: 'shot.png', mimeType: 'image/png', size: 2048, url: '/u/a' }] }), + message({ id: '00000000-0000-4000-8000-000000000002', sequence: 2, role: 'assistant', content: 'done', metadata: { blocks: [EDIT_BLOCK] } }), + ]) + const json = exportConversationJson(data) + expect(json.format).toBe(EXPORT_FORMAT) + expect(json.version).toBe(1) + expect(json.exportedAt).toBe(at.toISOString()) + expect(json.conversation).toMatchObject({ title: 'Fix the login page', agent: { name: 'Chat' }, pinnedAt: at.toISOString() }) + expect(JSON.stringify(json)).not.toContain('userId') + expect(json.messages).toHaveLength(2) + expect(json.messages[1].metadata).toEqual({ blocks: [EDIT_BLOCK] }) + expect(json.messages[0].attachments[0].filename).toBe('shot.png') + + expect(exportConversationMarkdown(data)).toContain('- `shot.png` (image/png, 2.0 KB)') + }) + + test('an agent switch shows as a note, not as the instructions it carried', () => { + const md = exportConversationMarkdown( + input([message({ role: 'system', content: '[Agent changed to Plan] You are now acting as Plan. Secret rules.', metadata: { type: 'agent_anchor' } })]), + ) + expect(md).toContain('_The conversation switched agents here._') + expect(md).not.toContain('Secret rules') + }) + + test('file names: an ASCII slug and the readable title, both dated', () => { + const names = exportFileNames('Fix the "login" page: café/2', at, 'md') + expect(names.ascii).toBe('fix-the-login-page-cafe-2-2026-09-23.md') + expect(names.utf8).toBe('Fix the login page café 2-2026-09-23.md') + expect(exportFileNames('???', at, 'json').ascii).toBe('conversation-2026-09-23.json') + expect(exportContentDisposition(names)).toBe( + `attachment; filename="fix-the-login-page-cafe-2-2026-09-23.md"; filename*=UTF-8''Fix%20the%20login%20page%20caf%C3%A9%202-2026-09-23.md`, + ) + }) + + test('file names: an emoji at the cut is kept whole, never split into half a character', () => { + // 79 characters and then an emoji, which is two UTF-16 units: a cut at 80 units lands + // between them, and the lone half made `encodeURIComponent` throw (a 500 on download). + const names = exportFileNames(`${'x'.repeat(79)}😀 more`, at, 'md') + expect(names.utf8).toBe(`${'x'.repeat(79)}😀-2026-09-23.md`) + expect(() => exportContentDisposition(names)).not.toThrow() + expect(exportContentDisposition(names)).toContain(`filename*=UTF-8''${'x'.repeat(79)}%F0%9F%98%80-2026-09-23.md`) + + // A title that already carries a lone half (it can arrive through JSON) is cleaned too. + const broken = exportFileNames('half \uD83D here', at, 'json') + expect(broken.utf8).toBe('half here-2026-09-23.json') + expect(() => exportContentDisposition(broken)).not.toThrow() + }) +}) + +test.describe('conversation export — the download route', () => { + test('a signed-in owner downloads Markdown and JSON as attachments that are not cached', async ({ page }) => { + const sql = getSql() + const userId = await getActiveUserId() + const prefix = uniquePrefix('conv-export') + await authenticateContext(page.context()) + + try { + const [conversation] = await sql<{ id: string }[]>` + insert into conversations (title, user_id, model, total_tokens, total_cost) + values (${`${prefix} Export me`}, ${userId}, ${'anthropic/claude-sonnet-4'}, 10, '0') + returning id + ` + await sql` + insert into messages (conversation_id, role, content, metadata, tool_calls, sequence) + values + (${conversation.id}, 'user', 'please edit it', '{}'::jsonb, '[]'::jsonb, 1), + (${conversation.id}, 'assistant', 'edited', ${sql.json({ blocks: [{ kind: 'text', content: 'edited' }, EDIT_BLOCK] })}, '[]'::jsonb, 2) + ` + + const md = await page.request.get(`/chat/${conversation.id}/export?format=md`) + expect(md.status()).toBe(200) + const mdHeaders = md.headers() + expect(mdHeaders['content-type']).toContain('text/markdown') + expect(mdHeaders['content-disposition']).toMatch(/^attachment; filename="[a-z0-9-]+-\d{4}-\d{2}-\d{2}\.md"; filename\*=UTF-8''/) + expect(mdHeaders['cache-control']).toBe('private, no-store') + expect(mdHeaders['x-content-type-options']).toBe('nosniff') + const mdBody = await md.text() + expect(mdBody).toContain(`# ${prefix} Export me`) + expect(mdBody).toContain('please edit it') + expect(mdBody).toContain('**Tool · Edit** `src/routes/login/+page.svelte`') + + const json = await page.request.get(`/chat/${conversation.id}/export?format=json`) + expect(json.status()).toBe(200) + expect(json.headers()['content-type']).toContain('application/json') + expect(json.headers()['content-disposition']).toContain('.json"') + const doc = (await json.json()) as { format: string; conversation: Record<string, unknown>; messages: Array<{ role: string; metadata: unknown }> } + expect(doc.format).toBe(EXPORT_FORMAT) + expect(doc.conversation.id).toBe(conversation.id) + expect(doc.conversation).not.toHaveProperty('userId') + expect(doc.messages.map((m) => m.role)).toEqual(['user', 'assistant']) + expect(doc.messages[1].metadata).toMatchObject({ blocks: [{ kind: 'text' }, { kind: 'tool', name: 'Edit' }] }) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('an unknown format is refused', async ({ page }) => { + const prefix = uniquePrefix('conv-export-format') + await authenticateContext(page.context()) + const sql = getSql() + const userId = await getActiveUserId() + try { + const [conversation] = await sql<{ id: string }[]>` + insert into conversations (title, user_id, model, total_tokens, total_cost) + values (${`${prefix} chat`}, ${userId}, ${'anthropic/claude-sonnet-4'}, 0, '0') + returning id + ` + const response = await page.request.get(`/chat/${conversation.id}/export?format=xml`) + expect(response.status()).toBe(400) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test("someone else's conversation, or no conversation at all, is not found", async ({ page }) => { + const prefix = uniquePrefix('conv-export-foreign') + await authenticateContext(page.context()) + const sql = getSql() + try { + // No owner: not the signed-in user's, which is all the route may know. + const [foreign] = await sql<{ id: string }[]>` + insert into conversations (title, user_id, model, total_tokens, total_cost) + values (${`${prefix} not yours`}, ${null}, ${'anthropic/claude-sonnet-4'}, 0, '0') + returning id + ` + expect((await page.request.get(`/chat/${foreign.id}/export?format=md`)).status()).toBe(404) + expect((await page.request.get('/chat/00000000-0000-4000-8000-000000000000/export?format=json')).status()).toBe(404) + expect((await page.request.get('/chat/not-a-uuid/export?format=md')).status()).toBe(404) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('without a session the download is refused', async ({ playwright }) => { + const context = await playwright.request.newContext() + try { + const response = await context.get('/chat/00000000-0000-4000-8000-000000000000/export?format=md', { maxRedirects: 0 }) + // The hook's redirect to /login answers first; the route's own 401 is the backstop. + expect([401, 302, 303]).toContain(response.status()) + } finally { + await context.dispose() + } + }) +}) diff --git a/tests/chat.conversation-lifecycle.spec.ts b/tests/chat.conversation-lifecycle.spec.ts new file mode 100644 index 00000000..ef225764 --- /dev/null +++ b/tests/chat.conversation-lifecycle.spec.ts @@ -0,0 +1,373 @@ +import { randomUUID } from 'node:crypto' +import { expect, test, type Locator, type Page } from '@playwright/test' +import { + answerConfirmDialog, + authenticateContext, + cleanupPrefixedRecords, + getActiveUserId, + getSql, + uniquePrefix, + waitForHydration, +} from './helpers' + +/** + * #18 — pin, archive, rename and delete. + * + * The rules live on the server and are pinned there first: pinning unarchives, archiving + * unpins, neither reorders the list (`updated_at` stays put), the default list leaves the + * archive out and always carries every pinned chat, and a message the user sends brings an + * archived chat back. Then the sidebar: the row menu does each of these, pinned chats sit in + * their own group on top, the Archived view finds and restores a chat, delete asks first and + * leaves the chat that was open, and on a phone the menu button is there without hovering. + * On a phone the home page's "Recent chats" are the latest chats, not the pinned ones the + * server lists first. + */ + +async function seedConversation(title: string, userId: string | null) { + const sql = getSql() + const [row] = await sql<{ id: string }[]>` + insert into conversations (title, user_id, model, total_tokens, total_cost) + values (${title}, ${userId}, ${'anthropic/claude-sonnet-4'}, 0, '0') + returning id + ` + return row.id +} + +async function lifecycleRow(id: string) { + const sql = getSql() + const [row] = await sql<{ pinned_at: Date | null; archived_at: Date | null; updated_at: Date; title: string }[]>` + select pinned_at, archived_at, updated_at, title from conversations where id = ${id} + ` + return row ?? null +} + +test.describe('conversation lifecycle — the rules', () => { + test('pinning unarchives, archiving unpins, and neither moves updated_at', async () => { + const prefix = uniquePrefix('conv-life-rules') + const userId = await getActiveUserId() + const { setConversationArchivedForUser, setConversationPinnedForUser } = await import( + '../src/lib/chat/conversation-lifecycle.server' + ) + + try { + const id = await seedConversation(`${prefix} chat`, userId) + const before = await lifecycleRow(id) + + await setConversationPinnedForUser(userId, id, true) + let row = await lifecycleRow(id) + expect(row?.pinned_at).not.toBeNull() + expect(row?.archived_at).toBeNull() + + await setConversationArchivedForUser(userId, id, true) + row = await lifecycleRow(id) + expect(row?.archived_at).not.toBeNull() + expect(row?.pinned_at).toBeNull() + + await setConversationPinnedForUser(userId, id, true) + row = await lifecycleRow(id) + expect(row?.pinned_at).not.toBeNull() + expect(row?.archived_at).toBeNull() + + await setConversationPinnedForUser(userId, id, false) + await setConversationArchivedForUser(userId, id, false) + row = await lifecycleRow(id) + expect(row?.pinned_at).toBeNull() + expect(row?.archived_at).toBeNull() + + expect(row?.updated_at.getTime()).toBe(before?.updated_at.getTime()) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test("another user's conversation cannot be pinned or archived", async () => { + const prefix = uniquePrefix('conv-life-foreign') + const userId = await getActiveUserId() + const { setConversationArchivedForUser, setConversationPinnedForUser } = await import( + '../src/lib/chat/conversation-lifecycle.server' + ) + + try { + const id = await seedConversation(`${prefix} mine`, userId) + const stranger = randomUUID() + // Not found for them — the remote functions answer 404 — and nothing changes. + expect(await setConversationPinnedForUser(stranger, id, true)).toBeNull() + expect(await setConversationArchivedForUser(stranger, id, true)).toBeNull() + const row = await lifecycleRow(id) + expect(row?.pinned_at).toBeNull() + expect(row?.archived_at).toBeNull() + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('the default list leaves the archive out and keeps every pinned chat; the archive lists it', async () => { + const prefix = uniquePrefix('conv-life-list') + const userId = await getActiveUserId() + const sql = getSql() + const { listArchivedConversations, listRecentConversations, conversationListVersion } = await import( + '../src/lib/chat/conversation-list.server' + ) + const { setConversationArchivedForUser, setConversationPinnedForUser } = await import( + '../src/lib/chat/conversation-lifecycle.server' + ) + + try { + const pinnedOld = await seedConversation(`${prefix} pinned long ago`, userId) + // Older than anything the recent list would reach: only the pin keeps it listed. + await sql`update conversations set updated_at = now() - interval '10 years' where id = ${pinnedOld}` + const archived = await seedConversation(`${prefix} archived`, userId) + const plain = await seedConversation(`${prefix} plain`, userId) + await sql` + insert into messages (conversation_id, role, content, metadata, tool_calls, sequence) + values (${plain}, 'assistant', ${'r'.repeat(5000)}, '{}'::jsonb, '[]'::jsonb, 1) + ` + + const v0 = await conversationListVersion(userId) + await setConversationPinnedForUser(userId, pinnedOld, true) + const v1 = await conversationListVersion(userId) + expect(v1).not.toBe(v0) + await setConversationArchivedForUser(userId, archived, true) + expect(await conversationListVersion(userId)).not.toBe(v1) + + // Room for one unpinned chat: the pinned one is listed anyway, first. + const tight = await listRecentConversations(userId, 1) + expect(tight.map((c) => c.id)).toContain(pinnedOld) + expect(tight[0].pinnedAt).not.toBeNull() + + const recent = await listRecentConversations(userId) + const ids = recent.map((c) => c.id) + expect(ids).toContain(pinnedOld) + expect(ids).toContain(plain) + expect(ids).not.toContain(archived) + // The preview is a line, not the whole reply. + expect(recent.find((c) => c.id === plain)?.lastMessage).toHaveLength(280) + + const archive = await listArchivedConversations(userId) + expect(archive.map((c) => c.id)).toContain(archived) + expect(archive.map((c) => c.id)).not.toContain(plain) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('a message the user sends brings an archived chat back', async () => { + const prefix = uniquePrefix('conv-life-unarchive') + const userId = await getActiveUserId() + const { setConversationArchivedForUser } = await import('../src/lib/chat/conversation-lifecycle.server') + const { resolveParentMessage } = await import('../src/lib/chat/stream-persistence.server') + const { insertMessageWithSequence } = await import('../src/lib/chat/insert-message.server') + + try { + const id = await seedConversation(`${prefix} archived`, userId) + await setConversationArchivedForUser(userId, id, true) + + // An automation writing into it does not. + await insertMessageWithSequence({ conversationId: id, role: 'assistant', content: 'scheduled report' }) + expect((await lifecycleRow(id))?.archived_at).not.toBeNull() + + const result = await resolveParentMessage({ conversationId: id, body: { content: 'one more thing' }, model: 'claude-sonnet-5' }) + expect(result.ok).toBe(true) + expect((await lifecycleRow(id))?.archived_at).toBeNull() + } finally { + await cleanupPrefixedRecords(prefix) + } + }) +}) + +/** The sidebar on a desktop, the navigation drawer on a phone. */ +async function openNav(page: Page, isMobile: boolean): Promise<Locator> { + if (!isMobile) return page.locator('aside.console-sb') + await page.getByRole('button', { name: 'Open navigation' }).filter({ visible: true }).first().click() + const drawer = page.getByRole('dialog', { name: 'Navigation drawer' }) + await expect(drawer).toBeVisible() + return drawer +} + +function chatItem(nav: Locator, id: string) { + return nav.locator(`.console-chatitem[data-conversation-id="${id}"]`) +} + +async function openRowMenu(nav: Locator, id: string) { + const item = chatItem(nav, id) + await item.hover() + await item.getByRole('button', { name: /^Actions for / }).click() + const menu = item.getByRole('menu') + await expect(menu).toBeVisible() + return menu +} + +async function chooseStatus(nav: Locator, status: 'All' | 'Running' | 'Archived') { + await nav.locator('.console-sb__filterbtn').click() + await nav.getByRole('combobox', { name: 'Status' }).selectOption(status) +} + +test.describe('conversation lifecycle — the sidebar', () => { + test('pin, archive, find in the archive, unarchive, rename and export from the row menu', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'desktop sidebar; the phone drawer is covered below') + test.setTimeout(90_000) + const prefix = uniquePrefix('conv-life-ui') + const userId = await getActiveUserId() + await authenticateContext(page.context()) + + try { + const id = await seedConversation(`${prefix} Lifecycle chat`, userId) + await page.goto('/', { waitUntil: 'domcontentloaded' }) + await waitForHydration(page) + const nav = await openNav(page, false) + const item = chatItem(nav, id) + await expect(item).toBeVisible({ timeout: 15_000 }) + + // Pin: into the Pinned group, above everything else. + await (await openRowMenu(nav, id)).getByRole('menuitem', { name: 'Pin to top' }).click() + await expect(nav.locator('.console-chatgroup.is-pinned')).toBeVisible() + await expect + .poll(() => + nav.locator('.console-sb__chatlist').evaluate((list, wanted) => { + const pinned: string[] = [] + let inPinned = false + for (const el of Array.from(list.children)) { + if (el.classList.contains('console-chatgroup')) inPinned = el.classList.contains('is-pinned') + else if (inPinned && el instanceof HTMLElement && el.dataset.conversationId) pinned.push(el.dataset.conversationId) + } + return pinned.includes(wanted) + }, id), + ) + .toBe(true) + expect((await lifecycleRow(id))?.pinned_at).not.toBeNull() + + // Rename. + await (await openRowMenu(nav, id)).getByRole('menuitem', { name: 'Rename' }).click() + const titleField = item.getByRole('textbox', { name: 'Conversation title' }) + await titleField.fill(`${prefix} Renamed chat`) + await titleField.press('Enter') + await expect(item.locator('a.console-chatrow')).toContainText(`${prefix} Renamed chat`) + expect((await lifecycleRow(id))?.title).toBe(`${prefix} Renamed chat`) + + // Export: a real download from the menu. + const menu = await openRowMenu(nav, id) + const [download] = await Promise.all([ + page.waitForEvent('download'), + menu.getByRole('menuitem', { name: 'Export as Markdown' }).click(), + ]) + expect(download.suggestedFilename()).toMatch(/-\d{4}-\d{2}-\d{2}\.md$/) + + // Archive — the one-click button on the row — takes it out of the list and the pin. + await item.hover() + await item.getByRole('button', { name: `Archive ${prefix} Renamed chat` }).click() + await expect(chatItem(nav, id)).toHaveCount(0) + const archivedRow = await lifecycleRow(id) + expect(archivedRow?.archived_at).not.toBeNull() + expect(archivedRow?.pinned_at).toBeNull() + + // The Archived view finds it, and Unarchive puts it back. + await chooseStatus(nav, 'Archived') + await expect(nav.getByText('Archived chats')).toBeVisible() + await expect(chatItem(nav, id)).toBeVisible({ timeout: 15_000 }) + // Other workers seed chats for the same owner meanwhile. Fresh activity keeps this one + // among the 50 most recent, so it is sure to be listed once it is back. + await getSql()`update conversations set updated_at = now() where id = ${id}` + await (await openRowMenu(nav, id)).getByRole('menuitem', { name: 'Unarchive' }).click() + await expect(chatItem(nav, id)).toHaveCount(0) + await nav.getByRole('button', { name: 'Back to chats' }).click() + await expect(chatItem(nav, id)).toBeVisible({ timeout: 15_000 }) + expect((await lifecycleRow(id))?.archived_at).toBeNull() + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('delete asks first, and deleting the open chat goes home', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'desktop sidebar; the phone drawer is covered below') + test.setTimeout(90_000) + const prefix = uniquePrefix('conv-life-delete') + const userId = await getActiveUserId() + await authenticateContext(page.context()) + + try { + const id = await seedConversation(`${prefix} Doomed chat`, userId) + await page.goto(`/chat/${id}`, { waitUntil: 'domcontentloaded' }) + await waitForHydration(page) + const nav = await openNav(page, false) + await expect(chatItem(nav, id)).toBeVisible({ timeout: 15_000 }) + + // Declining changes nothing. + await (await openRowMenu(nav, id)).getByRole('menuitem', { name: 'Delete…' }).click() + await answerConfirmDialog(page, 'Delete', { decline: true }) + expect(await lifecycleRow(id)).not.toBeNull() + + await (await openRowMenu(nav, id)).getByRole('menuitem', { name: 'Delete…' }).click() + await answerConfirmDialog(page, 'Delete') + await expect(page).toHaveURL(/\/$/) + await expect(chatItem(nav, id)).toHaveCount(0) + expect(await lifecycleRow(id)).toBeNull() + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('on a phone the menu button is shown without hovering, and archive works from the drawer', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'mobile', 'phone drawer') + test.setTimeout(90_000) + const prefix = uniquePrefix('conv-life-mobile') + const userId = await getActiveUserId() + await authenticateContext(page.context()) + + try { + const id = await seedConversation(`${prefix} Phone chat with a fairly long title that has to truncate`, userId) + await page.goto('/', { waitUntil: 'domcontentloaded' }) + await waitForHydration(page) + const nav = await openNav(page, true) + const item = chatItem(nav, id) + await expect(item).toBeVisible({ timeout: 15_000 }) + + const button = item.getByRole('button', { name: /^Actions for / }) + await expect(button).toBeVisible() + expect(await button.evaluate((el) => getComputedStyle(el.parentElement!).opacity)).toBe('1') + // The title keeps real width beside the button rather than collapsing to nothing. + expect(await item.locator('.console-chatrow .t').evaluate((el) => el.getBoundingClientRect().width)).toBeGreaterThan(80) + + await button.click() + const menu = item.getByRole('menu') + await expect(menu).toBeVisible() + const box = await menu.boundingBox() + const viewport = page.viewportSize()! + expect(box!.x + box!.width).toBeLessThanOrEqual(viewport.width + 1) + + await menu.getByRole('menuitem', { name: 'Archive' }).click() + await expect(chatItem(nav, id)).toHaveCount(0) + expect((await lifecycleRow(id))?.archived_at).not.toBeNull() + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('on a phone, the home page’s recent chats are the latest ones, not the pinned ones', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'mobile', 'the home page lists recent chats only where the sidebar is hidden') + test.setTimeout(60_000) + const prefix = uniquePrefix('conv-life-home') + const userId = await getActiveUserId() + const sql = getSql() + await authenticateContext(page.context()) + + try { + // Five chats pinned just now but quiet for years: the server lists them first. + for (let i = 0; i < 5; i++) { + const id = await seedConversation(`${prefix} pinned ${i}`, userId) + await sql`update conversations set pinned_at = now(), updated_at = now() - interval '10 years' where id = ${id}` + } + // Five fresh ones, so there are always five newer than the pinned ones. + for (let i = 0; i < 5; i++) await seedConversation(`${prefix} fresh ${i}`, userId) + + await page.goto('/', { waitUntil: 'domcontentloaded' }) + await waitForHydration(page) + const heading = page.getByRole('heading', { name: 'Recent chats' }) + await expect(heading).toBeVisible({ timeout: 15_000 }) + const section = heading.locator('xpath=..') + await expect(section.locator('a[href^="/chat/"]')).toHaveCount(5) + await expect(section.getByText(`${prefix} pinned`)).toHaveCount(0) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) +}) diff --git a/tests/chat.conversation-order.spec.ts b/tests/chat.conversation-order.spec.ts new file mode 100644 index 00000000..38023dcb --- /dev/null +++ b/tests/chat.conversation-order.spec.ts @@ -0,0 +1,160 @@ +import { expect, test } from '@playwright/test' +import { + groupConversations, + listTime, + sortConversations, + type OrderableConversation, +} from '../src/lib/chat/conversation-order' + +/** + * #18 — the order the sidebar shows conversations in. + * + * Pure: `conversation-order.ts` imports nothing but the day helpers, so this runs without a + * database or a server. The server sends pinned chats first by pin time and the archive by + * archive time; the sidebar used to re-sort everything by last activity and throw that away. + * What is pinned: + * - the Pinned group is ordered by when each chat was pinned, not by its last activity + * - the rest of the list is ordered by last activity, and a pinned chat is not repeated there + * - the archive is ordered, grouped by day and dated by when each chat was archived + * - "Sort by Name" is an explicit choice and applies inside the Pinned group too + * - groups are keyed by the day, so the same date a year apart makes two groups + */ + +/** Local noon on a day, so the day a timestamp falls on never depends on the time zone. */ +function day(year: number, month: number, date: number, hour = 12): Date { + return new Date(year, month - 1, date, hour) +} + +function chat(id: string, fields: Partial<OrderableConversation> = {}): OrderableConversation { + return { + id, + title: id, + category: null, + updatedAt: day(2026, 9, 1), + pinnedAt: null, + archivedAt: null, + ...fields, + } +} + +const ids = (list: Array<{ id: string }>) => list.map((c) => c.id) + +test.describe('conversation order', () => { + test('pinned chats come first, newest pin first, whatever their last activity', () => { + const list = [ + // Pinned long ago, but busy today. + chat('pinned-first', { pinnedAt: day(2026, 1, 1), updatedAt: day(2026, 9, 20) }), + // Pinned yesterday, quiet for months. + chat('pinned-last', { pinnedAt: day(2026, 9, 19), updatedAt: day(2025, 3, 1) }), + chat('recent', { updatedAt: day(2026, 9, 18) }), + chat('older', { updatedAt: day(2026, 9, 10) }), + ] + + const groups = groupConversations(list, { sortBy: 'Recency', groupBy: 'None', view: 'chats' }) + expect(groups.map((g) => g.key)).toEqual(['pinned', 'group:flat']) + expect(groups[0]).toMatchObject({ label: 'Pinned' }) + expect(ids(groups[0].items)).toEqual(['pinned-last', 'pinned-first']) + // The rest by last activity, with no pinned chat repeated below. + expect(groups[1]).toMatchObject({ label: 'Recent' }) + expect(ids(groups[1].items)).toEqual(['recent', 'older']) + }) + + test('with nothing pinned there is no Pinned group, and an ungrouped list needs no heading', () => { + const groups = groupConversations([chat('a', { updatedAt: day(2026, 9, 2) }), chat('b')], { + sortBy: 'Recency', + groupBy: 'None', + view: 'chats', + }) + expect(groups).toHaveLength(1) + expect(groups[0]).toMatchObject({ key: 'group:flat', label: '' }) + expect(ids(groups[0].items)).toEqual(['a', 'b']) + }) + + test('grouped by date, the days below the Pinned group are the days of last activity', () => { + const list = [ + chat('pinned', { pinnedAt: day(2026, 9, 21), updatedAt: day(2026, 9, 1) }), + chat('mon-late', { updatedAt: day(2026, 9, 14, 18) }), + chat('mon-early', { updatedAt: day(2026, 9, 14, 9) }), + chat('sun', { updatedAt: day(2026, 9, 13) }), + ] + const groups = groupConversations(list, { sortBy: 'Recency', groupBy: 'Date', view: 'chats' }) + expect(groups.map((g) => g.key)).toEqual(['pinned', 'day:2026-09-14', 'day:2026-09-13']) + expect(ids(groups[1].items)).toEqual(['mon-late', 'mon-early']) + // The pinned chat's own day (Sept 1) has no group: it is listed once, on top. + expect(groups.flatMap((g) => ids(g.items))).toEqual(['pinned', 'mon-late', 'mon-early', 'sun']) + }) + + test('the same date a year apart is two groups, not one', () => { + const groups = groupConversations( + [chat('this-year', { updatedAt: day(2026, 3, 14) }), chat('last-year', { updatedAt: day(2025, 3, 14) })], + { sortBy: 'Recency', groupBy: 'Date', view: 'chats' }, + ) + expect(groups.map((g) => g.key)).toEqual(['day:2026-03-14', 'day:2025-03-14']) + expect(new Set(groups.map((g) => g.key)).size).toBe(groups.length) + }) + + test('the archive is ordered, grouped and dated by when each chat was archived', () => { + const list = [ + // Last active long ago, archived today: it belongs at the top of the archive. + chat('archived-today', { updatedAt: day(2026, 6, 1), archivedAt: day(2026, 9, 22) }), + // Busy recently, archived last week. + chat('archived-earlier', { updatedAt: day(2026, 9, 21), archivedAt: day(2026, 9, 15) }), + chat('archived-same-day', { updatedAt: day(2026, 9, 20), archivedAt: day(2026, 9, 22, 8) }), + ] + + const flat = groupConversations(list, { sortBy: 'Recency', groupBy: 'None', view: 'archive' }) + expect(flat).toHaveLength(1) + expect(ids(flat[0].items)).toEqual(['archived-today', 'archived-same-day', 'archived-earlier']) + + const byDay = groupConversations(list, { sortBy: 'Recency', groupBy: 'Date', view: 'archive' }) + expect(byDay.map((g) => g.key)).toEqual(['day:2026-09-22', 'day:2026-09-15']) + expect(ids(byDay[0].items)).toEqual(['archived-today', 'archived-same-day']) + + // The row's time is the archive time there, and the last activity everywhere else. + expect(listTime(list[0], 'archive')).toEqual(day(2026, 9, 22)) + expect(listTime(list[0], 'chats')).toEqual(day(2026, 6, 1)) + }) + + test('the archive has no Pinned group', () => { + // Archiving unpins on the server; a stale row that says both is still shown as archived. + const groups = groupConversations([chat('both', { pinnedAt: day(2026, 9, 1), archivedAt: day(2026, 9, 2) })], { + sortBy: 'Recency', + groupBy: 'None', + view: 'archive', + }) + expect(groups.map((g) => g.key)).toEqual(['group:flat']) + }) + + test('sorting by name applies inside the Pinned group as well', () => { + const list = [ + chat('b-pinned', { title: 'Bravo', pinnedAt: day(2026, 9, 22) }), + chat('a-pinned', { title: 'Alpha', pinnedAt: day(2026, 9, 1) }), + chat('c', { title: 'Charlie' }), + chat('d', { title: 'Delta', updatedAt: day(2026, 9, 22) }), + ] + const groups = groupConversations(list, { sortBy: 'Name', groupBy: 'None', view: 'chats' }) + expect(ids(groups[0].items)).toEqual(['a-pinned', 'b-pinned']) + expect(ids(groups[1].items)).toEqual(['c', 'd']) + }) + + test('grouping by project puts chats with no category under Uncategorized', () => { + const groups = groupConversations( + [chat('x', { category: 'Infra' }), chat('y'), chat('z', { category: 'Infra', updatedAt: day(2026, 9, 5) })], + { sortBy: 'Recency', groupBy: 'Project', view: 'chats' }, + ) + expect(groups.map((g) => g.key)).toEqual(['category:Infra', 'category:Uncategorized']) + expect(ids(groups[0].items)).toEqual(['z', 'x']) + }) + + test('the "Recent chats" order ignores pins, and nothing sorts the list it was given', () => { + const list = [ + chat('pinned-old', { pinnedAt: day(2026, 9, 22), updatedAt: day(2020, 1, 1) }), + chat('newest', { updatedAt: day(2026, 9, 22) }), + chat('middle', { updatedAt: day(2026, 9, 10) }), + ] + const before = ids(list) + expect(ids(sortConversations(list, 'Recency', 'chats'))).toEqual(['newest', 'middle', 'pinned-old']) + groupConversations(list, { sortBy: 'Recency', groupBy: 'Date', view: 'chats' }) + expect(ids(list)).toEqual(before) + }) +}) diff --git a/tests/chat.conversation-search.spec.ts b/tests/chat.conversation-search.spec.ts new file mode 100644 index 00000000..c29affa8 --- /dev/null +++ b/tests/chat.conversation-search.spec.ts @@ -0,0 +1,358 @@ +import { randomUUID } from 'node:crypto' +import { expect, test } from '@playwright/test' +import { authenticateContext, cleanupPrefixedRecords, getActiveUserId, getSql, pollDb, uniquePrefix, waitForHydration } from './helpers' +import { SNIPPET_START, SNIPPET_STOP } from '../src/lib/chat/conversation-search' + +/** + * #18 — searching conversations on the server. + * + * The index is written by the application's own message writes (`insertMessageWithSequence` + * hands each committed row to the indexer), healed by the boot backfill, and searched with + * Postgres full-text search. What is pinned: + * - a file path in a tool call finds the turn by its full path, its file name, a partial + * path, one segment, and a prefix of that segment while typing + * - prose finds it too, and the snippet carries the highlight markers + * - the snippet shows a short message whole and a long one around the match — never just + * the matched word + * - archived chats are left out unless asked for; another user's are never returned + * - a search of only stop words still finds titles + * - the backfill indexes a message written behind the indexer's back, and rewrites a row + * built by an older version of the rules; a message deleted mid-batch is skipped, not fatal + * - deleting a message deletes its search row + * + * Each run makes up its own file name, so matches from other specs or real data cannot + * crowd it out of the results. + */ + +/** A word the English stemmer leaves alone (consonants, no suffix it strips). */ +function uniqueWord() { + const letters = 'bcdfghjkmnpqrtvwxz' + let word = 'zk' + for (let i = 0; i < 7; i++) word += letters[Math.floor(Math.random() * letters.length)] + return `${word}k` +} + +async function seedConversation(title: string, userId: string | null, options: { archived?: boolean } = {}) { + const sql = getSql() + const [row] = await sql<{ id: string }[]>` + insert into conversations (title, user_id, model, total_tokens, total_cost, archived_at) + values (${title}, ${userId}, ${'anthropic/claude-sonnet-4'}, 0, '0', ${options.archived ? new Date() : null}) + returning id + ` + return row.id +} + +async function searchRowFor(messageId: string) { + const sql = getSql() + const [row] = await sql<{ body: string; builder_version: number }[]>` + select body, builder_version from message_search where message_id = ${messageId} + ` + return row ?? null +} + +test.describe('conversation search', () => { + test('a tool call is found by its path, file name, partial path, segment and prefix; prose too', async () => { + const prefix = uniquePrefix('conv-search-path') + const word = uniqueWord() + const proseWord = uniqueWord() + const userId = await getActiveUserId() + const { insertMessageWithSequence } = await import('../src/lib/chat/insert-message.server') + const { searchUserConversations } = await import('../src/lib/chat/message-search.server') + + try { + const conversationId = await seedConversation(`${prefix} Engine work`, userId) + const path = `src/lib/engine/${word}.server.ts` + await insertMessageWithSequence({ + conversationId, + role: 'user', + content: `Please tidy the ${proseWord} options and ship it`, + }) + const assistant = await insertMessageWithSequence({ + conversationId, + role: 'assistant', + content: 'Done.', + metadata: { + blocks: [ + { kind: 'text', content: 'Done.' }, + { + kind: 'tool', + name: 'Edit', + arguments: { file_path: path, old_string: 'x', new_string: 'y' }, + result: 'ok', + success: true, + executionMs: 2, + details: { kind: 'file_edit', tool: 'Edit', path, changeType: 'update', hunks: [], additions: 1, deletions: 0, unavailable: 'none', truncated: false }, + }, + ], + }, + }) + + // Indexed by the write itself, in the background. + const row = await pollDb(() => searchRowFor(assistant.id), (r) => r !== null, { description: 'assistant message indexed' }) + expect(row!.body).toContain(path) + + for (const query of [path, `${word}.server.ts`, `engine/${word}.server.ts`, word, word.slice(0, -2)]) { + const hits = await searchUserConversations(userId, query) + const hit = hits.find((h) => h.conversationId === conversationId) + expect(hit, `search for ${query}`).toBeTruthy() + expect(hit!.match?.messageId, `search for ${query}`).toBe(assistant.id) + expect(hit!.match?.snippet).toContain(SNIPPET_START) + expect(hit!.match?.snippet).toContain(SNIPPET_STOP) + } + + // Prose: the user's message, with its own date and role. + const prose = await searchUserConversations(userId, `tidy ${proseWord} ship`) + const proseHit = prose.find((h) => h.conversationId === conversationId) + expect(proseHit?.match?.role).toBe('user') + expect(proseHit?.match?.createdAt).toBeInstanceOf(Date) + expect(proseHit?.title).toBe(`${prefix} Engine work`) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('the snippet shows a short message whole, and a long one around the match', async () => { + const prefix = uniquePrefix('conv-search-snippet') + const shortWord = uniqueWord() + const tinyWord = uniqueWord() + const longWord = uniqueWord() + const userId = await getActiveUserId() + const { indexMessage, searchUserConversations } = await import('../src/lib/chat/message-search.server') + const sql = getSql() + const mark = (word: string) => `${SNIPPET_START}${word}${SNIPPET_STOP}` + + try { + const conversationId = await seedConversation(`${prefix} snippets`, userId) + const filler = 'The quick brown fox jumps over the lazy dog while the engineer reviews the pull request carefully. ' + const bodies = [ + `fix the ${shortWord} bug now`, + // Every word around the match is three letters or fewer, which Postgres will not end an excerpt on. + `${tinyWord} it`, + `${filler.repeat(5)}Then we fix the ${longWord} bug now and redeploy. ${filler.repeat(5)}`, + ] + for (const [index, content] of bodies.entries()) { + const [message] = await sql<{ id: string }[]>` + insert into messages (conversation_id, role, content, metadata, tool_calls, sequence) + values (${conversationId}, 'user', ${content}, '{}'::jsonb, '[]'::jsonb, ${index + 1}) + returning id + ` + await indexMessage({ id: message.id, conversationId, role: 'user', content }) + } + + const snippetFor = async (word: string) => { + const hit = (await searchUserConversations(userId, word)).find((h) => h.conversationId === conversationId) + expect(hit?.match, `a message matched ${word}`).toBeTruthy() + return hit!.match!.snippet + } + + // Short messages are shown whole — the words around the match included. + expect(await snippetFor(shortWord)).toBe(`fix the ${mark(shortWord)} bug now`) + expect(await snippetFor(tinyWord)).toBe(`${mark(tinyWord)} it`) + + // A long one is cut down to the part around the match, with words on both sides. + const long = await snippetFor(longWord) + expect(long).toContain(`fix the ${mark(longWord)} bug now`) + expect(long.length).toBeLessThan(bodies[2].length / 2) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('archived chats only when asked for, and never another user’s', async () => { + const prefix = uniquePrefix('conv-search-scope') + const word = uniqueWord() + const userId = await getActiveUserId() + const { indexMessage, searchUserConversations } = await import('../src/lib/chat/message-search.server') + const sql = getSql() + + try { + const active = await seedConversation(`${prefix} active`, userId) + const archived = await seedConversation(`${prefix} archived`, userId, { archived: true }) + const foreign = await seedConversation(`${prefix} foreign`, null) + for (const conversationId of [active, archived, foreign]) { + const [message] = await sql<{ id: string }[]>` + insert into messages (conversation_id, role, content, metadata, tool_calls, sequence) + values (${conversationId}, 'user', ${`about ${word}`}, '{}'::jsonb, '[]'::jsonb, 1) + returning id + ` + await indexMessage({ id: message.id, conversationId, role: 'user', content: `about ${word}` }) + } + + const ids = (hits: Array<{ conversationId: string }>) => hits.map((h) => h.conversationId) + const defaultHits = await searchUserConversations(userId, word) + expect(ids(defaultHits)).toContain(active) + expect(ids(defaultHits)).not.toContain(archived) + expect(ids(defaultHits)).not.toContain(foreign) + + const withArchive = await searchUserConversations(userId, word, { includeArchived: true }) + expect(ids(withArchive)).toContain(active) + expect(ids(withArchive)).toContain(archived) + expect(withArchive.find((h) => h.conversationId === archived)?.archived).toBe(true) + expect(ids(withArchive)).not.toContain(foreign) + + // A stranger — any id that is not the owner — finds nothing of the owner's. + expect(await searchUserConversations(randomUUID(), word, { includeArchived: true })).toEqual([]) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('titles match too, including a search of only stop words', async () => { + const prefix = uniquePrefix('conv-search-title') + const userId = await getActiveUserId() + const { searchUserConversations } = await import('../src/lib/chat/message-search.server') + + try { + const conversationId = await seedConversation(`${prefix} over the and under`, userId) + // "the and" is nothing but stop words: no text query at all, titles only. + const hits = await searchUserConversations(userId, 'the and', { limit: 50 }) + const hit = hits.find((h) => h.conversationId === conversationId) + expect(hit).toMatchObject({ titleMatch: true, match: null }) + + // Too short to search. + expect(await searchUserConversations(userId, 'a')).toEqual([]) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('the backfill indexes what the writer missed and rebuilds rows from older rules', async () => { + const prefix = uniquePrefix('conv-search-backfill') + const word = uniqueWord() + const userId = await getActiveUserId() + const sql = getSql() + const { backfillMessageSearch, searchUserConversations } = await import('../src/lib/chat/message-search.server') + const { SEARCH_BUILDER_VERSION } = await import('../src/lib/chat/message-search-text') + + try { + const conversationId = await seedConversation(`${prefix} backfill`, userId) + // Written straight to the table, as history from before search existed was. + const [missed] = await sql<{ id: string }[]>` + insert into messages (conversation_id, role, content, metadata, tool_calls, sequence) + values (${conversationId}, 'assistant', 'ran it', ${sql.json({ blocks: [{ kind: 'tool', name: 'Bash', arguments: { command: `./${word}.sh --all` }, result: 'x'.repeat(200_000), success: true, executionMs: 1 }] })}, '[]'::jsonb, 1) + returning id + ` + + // Scoped to this conversation: other specs insert and delete messages meanwhile. + await backfillMessageSearch({ batchSize: 50, conversationId }) + const indexed = await searchRowFor(missed.id) + expect(indexed?.builder_version).toBe(SEARCH_BUILDER_VERSION) + expect(indexed?.body).toContain(`./${word}.sh --all`) + expect(indexed?.body).not.toContain('x'.repeat(100)) + expect((await searchUserConversations(userId, `${word}.sh`)).map((h) => h.conversationId)).toContain(conversationId) + + // A row built by an older version of the rules is rebuilt. + await sql`update message_search set builder_version = 0, body = 'stale' where message_id = ${missed.id}` + await backfillMessageSearch({ batchSize: 50, conversationId }) + const rebuilt = await searchRowFor(missed.id) + expect(rebuilt?.builder_version).toBe(SEARCH_BUILDER_VERSION) + expect(rebuilt?.body).toContain(word) + + // Deleting the message takes its search row with it. + await sql`delete from messages where id = ${missed.id}` + expect(await searchRowFor(missed.id)).toBeNull() + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('a message deleted between the backfill’s read and its write is skipped, not fatal', async () => { + const prefix = uniquePrefix('conv-search-gone') + const word = uniqueWord() + const userId = await getActiveUserId() + const sql = getSql() + const { indexMessagesSkippingGone } = await import('../src/lib/chat/message-search.server') + + try { + const conversationId = await seedConversation(`${prefix} gone`, userId) + const [kept] = await sql<{ id: string }[]>` + insert into messages (conversation_id, role, content, metadata, tool_calls, sequence) + values (${conversationId}, 'user', ${`still here ${word}`}, '{}'::jsonb, '[]'::jsonb, 1) + returning id + ` + // Read by the backfill, then deleted before its write: the id no longer exists. + const gone = randomUUID() + + // One gone row fails the whole multi-row insert on its foreign key; the batch is then + // written a row at a time and the gone one left out. + const written = await indexMessagesSkippingGone([ + { id: gone, conversationId, role: 'user', content: 'deleted meanwhile' }, + { id: kept.id, conversationId, role: 'user', content: `still here ${word}` }, + ]) + expect(written).toBe(1) + expect((await searchRowFor(kept.id))?.body).toContain(word) + expect(await searchRowFor(gone)).toBeNull() + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('re-indexing a message replaces what it is found by', async () => { + const prefix = uniquePrefix('conv-search-reindex') + const before = uniqueWord() + const after = uniqueWord() + const userId = await getActiveUserId() + const sql = getSql() + const { indexMessage, searchUserConversations } = await import('../src/lib/chat/message-search.server') + + try { + const conversationId = await seedConversation(`${prefix} edited`, userId) + const [message] = await sql<{ id: string }[]>` + insert into messages (conversation_id, role, content, metadata, tool_calls, sequence) + values (${conversationId}, 'user', ${before}, '{}'::jsonb, '[]'::jsonb, 1) + returning id + ` + await indexMessage({ id: message.id, conversationId, role: 'user', content: before }) + await indexMessage({ id: message.id, conversationId, role: 'user', content: after }) + + const ids = async (q: string) => (await searchUserConversations(userId, q)).map((h) => h.conversationId) + expect(await ids(after)).toContain(conversationId) + expect(await ids(before)).not.toContain(conversationId) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) +}) + +test.describe('conversation search — the sidebar', () => { + test('typing searches messages after a pause and shows the snippet', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'the sidebar is desktop chrome; the drawer mounts the same component') + test.setTimeout(60_000) + const prefix = uniquePrefix('conv-search-ui') + const word = uniqueWord() + const userId = await getActiveUserId() + const { indexMessage } = await import('../src/lib/chat/message-search.server') + const sql = getSql() + await authenticateContext(page.context()) + + try { + const conversationId = await seedConversation(`${prefix} Sidebar search`, userId) + const [message] = await sql<{ id: string }[]>` + insert into messages (conversation_id, role, content, metadata, tool_calls, sequence) + values (${conversationId}, 'user', ${`fix R&D <3 ${word} now`}, '{}'::jsonb, '[]'::jsonb, 1) + returning id + ` + await indexMessage({ id: message.id, conversationId, role: 'user', content: `fix R&D <3 ${word} now` }) + + await page.goto('/', { waitUntil: 'domcontentloaded' }) + await waitForHydration(page) + const sidebar = page.locator('aside.console-sb') + await sidebar.getByRole('textbox', { name: 'Search chats' }).fill(word) + + const results = sidebar.getByRole('region', { name: 'Search results in messages' }) + const hit = results.locator(`a.console-searchhit[href="/chat/${conversationId}"]`) + await expect(hit).toBeVisible({ timeout: 15_000 }) + await expect(hit).toContainText(`${prefix} Sidebar search`) + // The highlight is a <mark> and the rest is text: the snippet is never parsed as HTML. + const snippet = hit.locator('.console-searchhit__snippet') + await expect(snippet.locator('mark')).toHaveText(word) + await expect(snippet).toContainText(`R&D <3 ${word} now`) + await expect(snippet.locator('*')).toHaveCount(1) + + await hit.click() + await expect(page).toHaveURL(new RegExp(`/chat/${conversationId}$`)) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) +}) diff --git a/tests/chat.message-search-text.spec.ts b/tests/chat.message-search-text.spec.ts new file mode 100644 index 00000000..a03f99a0 --- /dev/null +++ b/tests/chat.message-search-text.spec.ts @@ -0,0 +1,281 @@ +import { expect, test } from '@playwright/test' +import { + buildMessageSearchText, + pathSearchTerms, + SEARCH_TEXT_MAX_CHARS, +} from '../src/lib/chat/message-search-text' +import { + normalizeSearchInput, + searchQueryParts, + SNIPPET_START, + SNIPPET_STOP, + splitSnippet, +} from '../src/lib/chat/conversation-search' + +/** + * #18 — what a message is found by in conversation search, and how a typed search becomes a + * Postgres text query. + * + * Pure-function tests: both modules import nothing, so this runs without Postgres or a dev + * server. The database half (the index, the ranking, the scoping) is + * `chat.conversation-search.spec.ts`. + * + * The owner's headline case is "the run where it touched options.server.ts". Postgres reads + * a path as one token, so without the segment expansion pinned here that search silently + * finds nothing — which is exactly the failure a spec has to hold. + */ + +function toolBlock(overrides: Record<string, unknown>) { + return { kind: 'tool', arguments: {}, result: '', success: true, executionMs: 1, ...overrides } +} + +test.describe('buildMessageSearchText — what is indexed', () => { + test('an Edit is found by its full path, its file name and each segment of it', () => { + const text = buildMessageSearchText({ + role: 'assistant', + content: 'Done.', + metadata: { + blocks: [ + toolBlock({ + name: 'Edit', + arguments: { file_path: 'src/lib/engine/options.server.ts', old_string: 'a', new_string: 'b' }, + details: { + kind: 'file_edit', + tool: 'Edit', + path: 'src/lib/engine/options.server.ts', + changeType: 'update', + hunks: [], + additions: 1, + deletions: 1, + unavailable: 'none', + truncated: false, + }, + }), + ], + }, + }) + expect(text).toContain('Edit:') + expect(text).toContain('src/lib/engine/options.server.ts') + expect(text).toContain(' options.server.ts ') + expect(text).toContain('src lib engine options server ts') + // The same path from `details` and from the arguments is spelled out once. + expect(text.split('src/lib/engine/options.server.ts').length - 1).toBe(1) + }) + + test('a shell call is found by its command, its description and the links it printed', () => { + const text = buildMessageSearchText({ + role: 'assistant', + content: 'Opened it.', + metadata: { + blocks: [ + toolBlock({ + name: 'Bash', + arguments: { command: 'gh pr create --title "Tidy the sidebar"', description: 'Open the pull request' }, + result: `${'Creating pull request…\n'.repeat(400)}https://github.com/derekhearst/AgentStudio/pull/93\n`, + }), + ], + }, + }) + expect(text).toContain('gh pr create --title "Tidy the sidebar"') + expect(text).toContain('Open the pull request') + expect(text).toContain('https://github.com/derekhearst/AgentStudio/pull/93') + // The rest of the output is not indexed, only the link in it. + expect(text).not.toContain('Creating pull request') + }) + + test('file bodies, edit payloads, thinking, notices and raw tool output are left out', () => { + const text = buildMessageSearchText({ + role: 'assistant', + content: 'Wrote the file.', + metadata: { + blocks: [ + { kind: 'thinking', content: 'privatethoughtmarker' }, + { kind: 'notice', notice: { kind: 'compacted', level: 'info', title: 'noticemarker', detail: null, persist: true } }, + toolBlock({ name: 'Write', arguments: { file_path: 'notes/plan.md', content: 'filebodymarker' } }), + toolBlock({ name: 'Edit', arguments: { file_path: 'a.ts', old_string: 'oldmarker', new_string: 'newmarker' } }), + toolBlock({ name: 'Read', arguments: { file_path: 'b.ts' }, result: 'rawresultmarker' }), + ], + }, + }) + for (const marker of ['privatethoughtmarker', 'noticemarker', 'filebodymarker', 'oldmarker', 'newmarker', 'rawresultmarker']) { + expect(text, marker).not.toContain(marker) + } + expect(text).toContain('notes/plan.md') + expect(text).toContain('Read: b.ts') + }) + + test('short unknown arguments are kept, long ones are not', () => { + const text = buildMessageSearchText({ + role: 'assistant', + content: '', + metadata: { + blocks: [ + toolBlock({ + name: 'mcp__github__create_issue', + arguments: { title: 'Sidebar search is slow', body: 'x'.repeat(301) }, + }), + ], + }, + }) + expect(text).toContain('mcp__github__create_issue:') + expect(text).toContain('Sidebar search is slow') + expect(text).not.toContain('x'.repeat(301)) + }) + + test('arguments stored as a JSON string, subagents, attachments and the older toolCalls are read', () => { + const fromString = buildMessageSearchText({ + role: 'assistant', + content: '', + metadata: { blocks: [toolBlock({ name: 'Grep', arguments: JSON.stringify({ pattern: 'resolveParentMessage' }) })] }, + }) + expect(fromString).toContain('resolveParentMessage') + + const subagent = buildMessageSearchText({ + role: 'assistant', + content: '', + metadata: { + blocks: [ + { kind: 'subagent', agentId: 't1', agentName: 'Researcher', conversationId: null, task: 'compare pgvector indexes', content: 'HNSW wins', success: true }, + ], + }, + }) + expect(subagent).toContain('Subagent Researcher: compare pgvector indexes HNSW wins') + + // #32 — a delegation has no tool block of its own; the child's card carries its calls on + // `transcript`, and the files and commands they touched find the turn. + const delegated = buildMessageSearchText({ + role: 'assistant', + content: '', + metadata: { + blocks: [ + { + kind: 'subagent', + agentId: 't2', + agentName: 'Coder', + conversationId: null, + task: 'fix the stream route', + content: '', + success: true, + status: 'completed', + transcript: [ + { kind: 'tool', name: 'Read', label: 'src/lib/engine/options.server.ts', success: true }, + { kind: 'tool', name: 'Bash', label: 'bun run check', success: true }, + { kind: 'tool', name: 'Read', label: 'src/lib/engine/options.server.ts', success: true }, + ], + details: { kind: 'subagent', tool: 'Agent', status: 'completed', report: 'Patched the retry path.' }, + }, + ], + }, + }) + expect(delegated).toContain('Subagent Coder: fix the stream route Patched the retry path.') + expect(delegated).toContain('Read src/lib/engine/options.server.ts') + expect(delegated).toContain('Bash bun run check') + expect(delegated).toContain('src lib engine options server ts') + // A call repeated with the same label is indexed once. + expect(delegated.split('Read src/lib/engine/options.server.ts').length - 1).toBe(1) + + const withAttachment = buildMessageSearchText({ + role: 'user', + content: 'See the screenshot', + attachments: [{ id: '1', filename: 'login-error.png', mimeType: 'image/png', size: 10, url: '/x' }], + }) + expect(withAttachment).toContain('login-error.png') + expect(withAttachment).toContain('login error png') + + const legacy = buildMessageSearchText({ + role: 'assistant', + content: 'old turn', + toolCalls: [{ name: 'read_file', arguments: { path: 'docs/legacy/notes.md' }, result: 'x', status: 'completed' }], + }) + expect(legacy).toContain('read_file: docs/legacy/notes.md') + }) + + test('paths mentioned in prose get the same segment expansion', () => { + const text = buildMessageSearchText({ role: 'assistant', content: 'I changed conversation-list.server.ts.' }) + expect(text).toContain('conversation list server ts') + }) + + test('a system message indexes as nothing', () => { + expect(buildMessageSearchText({ role: 'system', content: '[Agent changed to Plan] You are now acting as Plan.' })).toBe('') + }) + + test('the snippet markers cannot be forged by a message', () => { + const text = buildMessageSearchText({ role: 'user', content: `a${SNIPPET_START}fake${SNIPPET_STOP}b` }) + expect(text).not.toContain(SNIPPET_START) + expect(text).not.toContain(SNIPPET_STOP) + }) + + test('the text is capped however large the message is', () => { + const huge = 'word '.repeat(1_000_000) // 5MB + const text = buildMessageSearchText({ + role: 'assistant', + content: huge, + metadata: { + blocks: [ + toolBlock({ name: 'Read', arguments: { file_path: 'big.log' }, result: huge }), + toolBlock({ name: 'Write', arguments: { file_path: 'big.txt', content: huge } }), + ], + }, + }) + expect(text.length).toBeLessThanOrEqual(SEARCH_TEXT_MAX_CHARS) + expect(text).toContain('Read: big.log') + }) + + test('pathSearchTerms', () => { + expect(pathSearchTerms('src/lib/engine/options.server.ts')).toBe( + 'src/lib/engine/options.server.ts options.server.ts src lib engine options server ts', + ) + expect(pathSearchTerms('README')).toBe('README') + expect(pathSearchTerms(' ')).toBe('') + }) +}) + +test.describe('searchQueryParts — how a search becomes a text query', () => { + test('a path-shaped search also looks for its segments as a phrase', () => { + expect(searchQueryParts('options.server.ts')).toEqual({ + exact: 'options.server.ts', + segmented: '"options server ts"', + prefix: null, + }) + expect(searchQueryParts('engine/options.server.ts')).toEqual({ + exact: 'engine/options.server.ts', + segmented: '"engine options server ts"', + prefix: null, + }) + }) + + test('the last plain word matches as a prefix, so results appear while typing', () => { + expect(searchQueryParts('deplo')).toEqual({ exact: null, segmented: null, prefix: 'deplo:*' }) + expect(searchQueryParts('fix the Sideb')).toEqual({ exact: 'fix the', segmented: null, prefix: 'sideb:*' }) + }) + + test('no prefix inside an open quote, after an OR, for an operator or a single letter', () => { + expect(searchQueryParts('"fix the side').prefix).toBeNull() + expect(searchQueryParts('login or signup').prefix).toBeNull() + expect(searchQueryParts('login or').prefix).toBeNull() + expect(searchQueryParts('fix a').prefix).toBeNull() + expect(searchQueryParts('fix a').exact).toBe('fix a') + }) + + test('a negated or quoted term is left as typed', () => { + expect(searchQueryParts('-options.server.ts deploy').segmented).toBeNull() + expect(searchQueryParts('"options.server.ts" deploy').segmented).toBeNull() + }) + + test('input is normalized, and empty input is empty', () => { + expect(normalizeSearchInput(' a\t\u0002b \n c ')).toBe('a b c') + expect(searchQueryParts(' ')).toEqual({ exact: null, segmented: null, prefix: null }) + }) +}) + +test.describe('splitSnippet — highlights as text, never as HTML', () => { + test('marked runs are split out and nothing is interpreted', () => { + expect(splitSnippet(`the ${SNIPPET_START}<b>options</b>${SNIPPET_STOP} file`)).toEqual([ + { text: 'the ', mark: false }, + { text: '<b>options</b>', mark: true }, + { text: ' file', mark: false }, + ]) + expect(splitSnippet('plain')).toEqual([{ text: 'plain', mark: false }]) + expect(splitSnippet('')).toEqual([]) + }) +})