From 4b7b2be980848c62335fed1556ef23bbd295d171 Mon Sep 17 00:00:00 2001 From: Derek Hearst Date: Wed, 23 Sep 2026 16:07:48 -0600 Subject: [PATCH 01/23] feat(chat): pin, archive and search columns for conversations (#18) conversations gains pinned_at and archived_at. Timestamps rather than booleans: set means on, and the time orders the pinned group and dates the archive. message_search is a side table with one row per message: the text worth finding it by (body, built in code) and a tsvector generated from it with a GIN index. A side table rather than columns on messages, because the chat page selects every messages column and would ship the search text to the browser, and because a builder_version column lets the index be rebuilt when what is indexed changes. Both FKs cascade, so deleting a message or a conversation removes its search rows. Migration 0079, generated by db:generate. Co-Authored-By: Claude Opus 5.5 --- drizzle/0079_conversation_lifecycle.sql | 15 + drizzle/meta/0079_snapshot.json | 8050 +++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/lib/sessions/sessions.schema.ts | 70 +- 4 files changed, 8141 insertions(+), 1 deletion(-) create mode 100644 drizzle/0079_conversation_lifecycle.sql create mode 100644 drizzle/meta/0079_snapshot.json 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/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(), + // #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), + ], +) From db748e01be5b43aed9ee515d7f2ef207a4d9fa04 Mon Sep 17 00:00:00 2001 From: Derek Hearst Date: Wed, 23 Sep 2026 16:16:54 -0600 Subject: [PATCH 02/23] feat(chat): index messages and tool calls for conversation search (#18) buildMessageSearchText turns a message into the text it is found by: the message itself, attachment names, and one line per tool call with its name, file paths, commands, short arguments and any links in its output. Raw tool output, file bodies, edit payloads, thinking and system messages are left out, and the result is capped at 60k characters so an insert can never hit the tsvector size limit. Postgres reads src/lib/engine/options.server.ts as a single token, so a search for "options" or "engine/options.server.ts" would miss the turn that edited it. Every path is followed by its basename and its segments as words, and searchQueryParts rewrites a path-shaped search as the matching phrase of segments. The last plain word is matched as a prefix, so results appear while typing. searchUserConversations ranks the best message per conversation with ts_rank_cd, highlights it with ts_headline using control-character markers (the sidebar renders them as text, never HTML), merges in title matches, and is scoped to the caller and to unarchived chats unless asked. Indexing never fails or delays a message write: insertMessageWithSequence hands a committed row to scheduleMessageIndex, and anything missed is picked up by backfillMessageSearch, which runs in the background at boot. It walks messages by id and trims tool output in Postgres before sending it. Co-Authored-By: Claude Opus 5.5 --- src/lib/chat/conversation-search.ts | 100 ++++++++++ src/lib/chat/insert-message.server.ts | 9 +- src/lib/chat/message-search-sql.ts | 149 ++++++++++++++ src/lib/chat/message-search-text.ts | 276 ++++++++++++++++++++++++++ src/lib/chat/message-search.server.ts | 239 ++++++++++++++++++++++ src/lib/db/bootstrap.server.ts | 22 +- 6 files changed, 791 insertions(+), 4 deletions(-) create mode 100644 src/lib/chat/conversation-search.ts create mode 100644 src/lib/chat/message-search-sql.ts create mode 100644 src/lib/chat/message-search-text.ts create mode 100644 src/lib/chat/message-search.server.ts 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 `` so the snippet is + * plain text end to end: it is built from user and agent content, and the sidebar turns the + * markers into `` 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[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`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-search-sql.ts b/src/lib/chat/message-search-sql.ts new file mode 100644 index 00000000..c9af2a41 --- /dev/null +++ b/src/lib/chat/message-search-sql.ts @@ -0,0 +1,149 @@ +/** + * 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`. + */ +export function backfillBatchQuery(cursor: string | null, batchSize: number): SQL { + const after: SQL = cursor ? sql`and m.id > ${cursor}::uuid` : 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}) ${after} + order by m.id + limit ${batchSize} + ` +} + +export const HEADLINE_OPTIONS = `StartSel="${SNIPPET_START}", StopSel="${SNIPPET_STOP}", MaxFragments=2, MaxWords=18, MinWords=6, FragmentDelimiter=" … "` + +/** 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, + ts_headline('english', ms.body, ${tsq}, ${HEADLINE_OPTIONS}) 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..c3cda1dd --- /dev/null +++ b/src/lib/chat/message-search-text.ts @@ -0,0 +1,276 @@ +/** + * 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. + */ +export const SEARCH_BUILDER_VERSION = 1 + +/** 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 +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 | null { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : 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() + 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() + 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 | 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 { + 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(' ') +} + +function subagentLine(item: Record): string { + const name = typeof item.agentName === 'string' ? item.agentName : 'subagent' + const task = typeof item.task === 'string' ? clip(item.task, SUBAGENT_MAX_CHARS) : '' + const content = typeof item.content === 'string' ? clip(item.content, SUBAGENT_MAX_CHARS) : '' + return [`Subagent ${name}:`, task, content].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..866e1d4d --- /dev/null +++ b/src/lib/chat/message-search.server.ts @@ -0,0 +1,239 @@ +/** + * 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 { 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 { + 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 { + 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, + }) + }) +} + +/** + * 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 and in several processes at once: each write is an upsert. + */ +export async function backfillMessageSearch(options: { batchSize?: number; maxBatches?: number } = {}): 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(backfillBatchQuery(cursor, batchSize)) + if (rows.length === 0) break + + await indexMessages( + 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, + })), + ) + indexed += rows.length + 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 { + 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(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() + 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/db/bootstrap.server.ts b/src/lib/db/bootstrap.server.ts index d9147424..b818e8c7 100644 --- a/src/lib/db/bootstrap.server.ts +++ b/src/lib/db/bootstrap.server.ts @@ -427,9 +427,11 @@ async function startWorkerAndScheduler(generation: number): Promise { } /** - * 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 () => { @@ -443,4 +445,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) + } + })() } From ea75a4ce2212e03ca7daa2de6af5cb0f24ba8272 Mon Sep 17 00:00:00 2001 From: Derek Hearst Date: Wed, 23 Sep 2026 16:16:54 -0600 Subject: [PATCH 03/23] feat(chat): pin, archive and search remote functions (#18) setConversationPinned and setConversationArchived set or clear the timestamps, scoped to the caller. Pinning unarchives and archiving unpins, and neither moves updatedAt, so tidying a chat never reorders it. A message the user sends into an archived chat brings it back; automations and monitors writing into one do not. getConversations now leaves archived chats out and returns every pinned chat on top of the 50 most recent, with a 280-character preview instead of the whole last reply. getArchivedConversations lists the archive. The list fingerprint the monitor pushes also counts pins and archives, since those no longer move updatedAt. searchConversations is the remote query behind the sidebar search. The stream's final assistant message and an edited message are indexed after they are committed. Co-Authored-By: Claude Opus 5.5 --- src/lib/chat/chat.remote.ts | 58 ++++++++++++++- src/lib/chat/conversation-lifecycle.server.ts | 72 +++++++++++++++++++ src/lib/chat/conversation-list.server.ts | 61 ++++++++++++++-- src/lib/chat/stream-persistence.server.ts | 9 ++- 4 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 src/lib/chat/conversation-lifecycle.server.ts diff --git a/src/lib/chat/chat.remote.ts b/src/lib/chat/chat.remote.ts index d37d478c..246f96b6 100644 --- a/src/lib/chat/chat.remote.ts +++ b/src/lib/chat/chat.remote.ts @@ -16,7 +16,10 @@ import { } from '$lib/chat/agent-switch.server' import { BUILTIN_AGENT_KEYS } from '$lib/agents/builtin-agents.server' import { insertMessageWithSequence } from '$lib/chat/insert-message.server' -import { listRecentConversations } from '$lib/chat/conversation-list.server' +import { listArchivedConversations, listRecentConversations } from '$lib/chat/conversation-list.server' +import { setConversationArchivedForUser, setConversationPinnedForUser } from '$lib/chat/conversation-lifecycle.server' +import { scheduleMessageIndex, 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, @@ -159,7 +162,14 @@ export const editMessage = command(editMessageSchema, async (input) => { return { success: false, error: 'Message not found or not editable' as const } } - await db.update(messages).set({ content: input.content }).where(eq(messages.id, input.messageId)) + const [edited] = await db + .update(messages) + .set({ content: input.content }) + .where(eq(messages.id, input.messageId)) + .returning() + // #18 — search finds the message by what it says now. The followers deleted below take + // their search rows with them (cascade). + scheduleMessageIndex(edited) await db .delete(messages) @@ -414,3 +424,47 @@ 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) + 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) + 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-lifecycle.server.ts b/src/lib/chat/conversation-lifecycle.server.ts new file mode 100644 index 00000000..915ab993 --- /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, +} + +export async function setConversationPinnedForUser( + userId: string, + conversationId: string, + pinned: boolean, +): Promise { + 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) + if (!row) throw new Error('Conversation not found') + return row +} + +export async function setConversationArchivedForUser( + userId: string, + conversationId: string, + archived: boolean, +): Promise { + 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) + if (!row) throw new Error('Conversation not found') + return row +} + +/** + * 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 { + 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`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 { .select({ count: sql`count(*)::int`, latest: sql`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`count(${conversations.pinnedAt})::int`, + latestPin: sql`max(${conversations.pinnedAt})::text`, + archived: sql`count(${conversations.archivedAt})::int`, + latestArchive: sql`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/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 } /** From a0a6933d72064eae61e4f8eba96a943b91efbb21 Mon Sep 17 00:00:00 2001 From: Derek Hearst Date: Wed, 23 Sep 2026 16:16:54 -0600 Subject: [PATCH 04/23] feat(chat): export a conversation as Markdown or JSON (#18) GET /chat/[id]/export?format=md|json downloads one conversation. Markdown is a readable transcript: a header with dates, agent, model and totals, then each message with its blocks in order - edits as diffs, shell calls as the command and its output, todo lists as checklists, subagents as quotes, thinking folded into
. Long tool output is cut short with a pointer to the JSON, and every code fence is longer than any run of backticks inside it, so tool output cannot break out. JSON has everything unshortened: the conversation (without the owner's id) and every message with its full metadata and blocks. Someone else's conversation answers 404, like one that does not exist. The response is an attachment with an ASCII and a UTF-8 file name, not cacheable and nosniff. Co-Authored-By: Claude Opus 5.5 --- src/lib/chat/conversation-export.server.ts | 65 ++++ src/lib/chat/conversation-export.ts | 391 +++++++++++++++++++++ src/routes/chat/[id]/export/+server.ts | 43 +++ 3 files changed, 499 insertions(+) create mode 100644 src/lib/chat/conversation-export.server.ts create mode 100644 src/lib/chat/conversation-export.ts create mode 100644 src/routes/chat/[id]/export/+server.ts 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..bb90cbcf --- /dev/null +++ b/src/lib/chat/conversation-export.ts @@ -0,0 +1,391 @@ +/** + * 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') +} + +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': { + const status = block.success === false || block.status === 'failed' ? ' ✗' : '' + const task = str(block.task).trim() + const body = str(block.content).trim() + const head = `**Subagent · ${str(block.agentName) || 'subagent'}**${status}${task ? ` — ${task.replace(/\s*\n\s*/g, ' ')}` : ''}` + return quote(body ? `${head}\n\n${body}` : head) + } + 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 = + title + .replace(/[\u0000-\u001f\u007f/\\:*?"<>|]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 80) + .trim() || 'conversation' + return { ascii: `${slug}-${day}.${extension}`, utf8: `${readable}-${day}.${extension}` } +} + +/** 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/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', + }, + }) +} From 879c44528a6d79c373d7ddb9d3da68e8a43ef4de Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 16:22:52 -0600 Subject: [PATCH 05/23] feat(chat-console): conversation menu, pinned group, archive view and search (#18) Each sidebar row gets a menu: Archive (first, and a one-click button on the row on a pointer device), Pin to top, Rename, Export as Markdown or JSON, and Delete last, behind the danger confirm dialog, which says what goes with it and suggests archiving instead. Deleting the open chat goes home. The menu opens inline under the row, so the scrolling list and the phone drawer never clip it, and on a touch screen or a phone-width drawer the button is always shown and the row makes room for it. Pinned chats form their own group above the others in every grouping. The Status filter's "Archived" used to mean "no running turn"; it is now the archive, with a bar to get back, and "Active" is renamed "Running". The search box keeps its instant filter over loaded titles and, 250ms after typing stops, asks the server. Matches in messages and tool calls show under "In messages" with the title, an archived badge, the date and the snippet, whose highlights are rendered as <mark> elements from the markers - never as HTML from the database. A pin, archive, rename or delete re-runs the search, and the archive view refreshes when another tab changes the list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/lib/chat-console/ConsoleNavContent.svelte | 199 ++++++++++--- .../chat-console/ConversationRowMenu.svelte | 267 ++++++++++++++++++ src/lib/chat-console/Icon.svelte | 13 +- src/lib/chat-console/console.css | 239 ++++++++++++++++ 4 files changed, 681 insertions(+), 37 deletions(-) create mode 100644 src/lib/chat-console/ConversationRowMenu.svelte diff --git a/src/lib/chat-console/ConsoleNavContent.svelte b/src/lib/chat-console/ConsoleNavContent.svelte index 7dc6407b..f781f5bd 100644 --- a/src/lib/chat-console/ConsoleNavContent.svelte +++ b/src/lib/chat-console/ConsoleNavContent.svelte @@ -2,11 +2,14 @@ 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 ConversationRowMenu from './ConversationRowMenu.svelte'; import { dayKey, dayLabel } from '$lib/util/relative-time'; const THEME_STORAGE_KEY = 'AgentStudio-theme'; @@ -28,6 +31,7 @@ } type Conversation = Awaited<ReturnType<typeof getConversations>>[number]; + type SearchHit = Awaited<ReturnType<typeof searchConversations>>[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<Conversation[]>(conversationsQuery?.current ?? []); function formatUsd(value: number): string { if (value >= 100) return `$${value.toFixed(0)}`; @@ -83,14 +86,27 @@ } 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<Conversation[]>( + (showingArchive ? archivedQuery?.current : conversationsQuery?.current) ?? [], + ); + 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,8 +140,7 @@ 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; }); }); @@ -143,11 +158,26 @@ return arr.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); }); - const grouped = $derived.by(() => { - if (groupBy === 'None') return [['', sorted] as [string, Conversation[]]]; + type Group = { key: string; label: string; items: Conversation[] }; + + /** + * Pinned chats (#18) form their own group above the others whatever the grouping, and are + * left out of the groups below. The archive has no pinned group: archiving unpins. + */ + const grouped = $derived.by((): Group[] => { + const pinned = showingArchive ? [] : sorted.filter((c) => c.pinnedAt); + const rest = pinned.length > 0 ? sorted.filter((c) => !c.pinnedAt) : sorted; + const groups = groupConversations(rest, pinned.length > 0) + .filter(([, items]) => items.length > 0) + .map(([label, items]) => ({ key: `group:${label}`, label, items })); + return pinned.length > 0 ? [{ key: 'pinned', label: 'Pinned', items: pinned }, ...groups] : groups; + }); + + function groupConversations(list: Conversation[], labelFlat: boolean): Array<[string, Conversation[]]> { + if (groupBy === 'None') return [[labelFlat ? 'Recent' : '', list]]; if (groupBy === 'Date') { const m = new Map<string, { label: string; ts: number; items: Conversation[] }>(); - for (const c of sorted) { + for (const c of list) { const key = dayKey(c.updatedAt); const existing = m.get(key); if (existing) existing.items.push(c); @@ -163,14 +193,60 @@ } const fieldKey: keyof Conversation = groupBy === 'Project' ? 'category' : groupBy === 'Status' ? 'category' : 'category'; const m = new Map<string, Conversation[]>(); - for (const c of sorted) { + for (const c of list) { const key = (c[fieldKey] as string | null) ?? 'Uncategorized'; if (!m.has(key)) m.set(key, []); m.get(key)!.push(c); } return [...m.entries()]; + } + + /* + * #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<SearchHit[]>([]); + 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; + } + 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); + }); + + /** 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 +344,10 @@ <div class="console-fmenu" onmouseleave={() => (openMenu = false)}> <div class="console-fmenu__row"> <span class="l">Status</span> - <select class="console-fmenu__sel" bind:value={filters.status}> - <option>Active</option> - <option>Archived</option> + <select class="console-fmenu__sel" bind:value={filters.status} aria-label="Status"> <option>All</option> + <option>Running</option> + <option>Archived</option> </select> </div> <div class="console-fmenu__sep"></div> @@ -296,37 +372,88 @@ {/if} <div class="console-sb__chatlist"> - {#each grouped as [label, items] (label || 'flat')} - {#if label} - <div class="console-chatgroup"> - <span>{label}</span> - <span class="ct">{items.length}</span> + {#if showingArchive} + <div class="console-archivebar"> + <span>Archived chats</span> + <button type="button" onclick={() => (filters.status = 'All')}>Back to chats</button> + </div> + {/if} + {#each grouped as group (group.key)} + {#if group.label} + <div class="console-chatgroup {group.key === 'pinned' ? 'is-pinned' : ''}"> + <span>{group.label}</span> + <span class="ct">{group.items.length}</span> </div> {/if} - {#each items as conversation (conversation.id)} + {#each group.items as conversation (conversation.id)} {@const run = runFor(conversation)} - <a - class="console-chatrow {activeChatId === conversation.id ? 'active' : ''}" - href={`/chat/${conversation.id}`} - onclick={onNavigate} - > - <span> - {#if run} - <span class="pulse-dot"></span> - {:else} - <span class="pulse-dot idle"></span> - {/if} - </span> - <span class="t">{conversation.title}</span> - <span class="s">· {relativeShort(conversation.updatedAt)}</span> - </a> + <div class="console-chatitem" data-conversation-id={conversation.id}> + <a + class="console-chatrow {activeChatId === conversation.id ? 'active' : ''}" + href={`/chat/${conversation.id}`} + onclick={onNavigate} + > + <span> + {#if run} + <span class="pulse-dot"></span> + {:else} + <span class="pulse-dot idle"></span> + {/if} + </span> + <span class="t">{conversation.title}</span> + <span class="s">· {relativeShort(conversation.updatedAt)}</span> + </a> + <ConversationRowMenu + {conversation} + active={activeChatId === conversation.id} + onChanged={rerunSearch} + {onNavigate} + /> + </div> {/each} {/each} - {#if conversations.length === 0} - <div class="console-chatempty">No conversations yet</div> - {:else if sorted.length === 0} + {#if conversations.length === 0 && messageHits.length === 0} + <div class="console-chatempty">{showingArchive ? 'Nothing archived.' : 'No conversations yet'}</div> + {:else if sorted.length === 0 && messageHits.length === 0 && searchStatus !== 'loading'} <div class="console-chatempty">No chats match.</div> {/if} + + {#if searchStatus !== 'idle'} + <section class="console-searchhits" aria-label="Search results in messages"> + <div class="console-chatgroup"> + <span>In messages</span> + <span class="ct">{searchStatus === 'loading' ? '…' : messageHits.length}</span> + </div> + {#if searchStatus === 'error'} + <div class="console-chatempty">Search failed. Try again.</div> + {:else if searchStatus === 'done' && messageHits.length === 0} + <div class="console-chatempty">Nothing found in messages.</div> + {/if} + {#each messageHits as hit (hit.conversationId)} + {@const when = new Date(hit.match?.createdAt ?? hit.updatedAt)} + <a + class="console-searchhit {activeChatId === hit.conversationId ? 'active' : ''}" + href={`/chat/${hit.conversationId}`} + onclick={onNavigate} + > + <span class="console-searchhit__head"> + <span class="t">{hit.title}</span> + {#if hit.archived} + <span class="console-searchhit__badge">archived</span> + {/if} + <time class="s" datetime={when.toISOString()} title={when.toLocaleString()}>{relativeShort(when)}</time> + </span> + {#if hit.match} + <span class="console-searchhit__snippet"> + {#each splitSnippet(hit.match.snippet) as part, i (i)} + {#if part.mark}<mark>{part.text}</mark>{:else}{part.text}{/if} + {/each} + </span> + {/if} + </a> + {/each} + </section> + {/if} </div> </div> diff --git a/src/lib/chat-console/ConversationRowMenu.svelte b/src/lib/chat-console/ConversationRowMenu.svelte new file mode 100644 index 00000000..2bba91eb --- /dev/null +++ b/src/lib/chat-console/ConversationRowMenu.svelte @@ -0,0 +1,267 @@ +<script lang="ts"> + /** + * #18 — the actions for one conversation in the sidebar list. + * + * Archive is the primary lifecycle action: it is the one-click button on the row (on a + * pointer device) and the first item in the menu, because it tidies the list without + * losing anything. Delete is last, behind a confirmation, because it cannot be undone. + * + * The menu opens inline under the row rather than as a floating popover, so the + * scrolling chat list and the phone drawer never clip it. On a touch screen the "⋯" button + * is always shown; on a pointer device it appears on hover or focus. + */ + import { tick } from 'svelte'; + import { goto } from '$app/navigation'; + import { + deleteConversation, + getArchivedConversations, + getConversation, + getConversations, + setConversationArchived, + setConversationPinned, + updateConversationMeta, + } from '$lib/chat'; + import { confirmDialog } from '$lib/ui/confirm-dialog.svelte'; + import { remoteErrorMessage } from '$lib/ui/remote-error'; + import Icon from './Icon.svelte'; + + type RowConversation = { + id: string; + title: string; + pinnedAt: Date | string | null; + archivedAt: Date | string | null; + }; + + let { + conversation, + active = false, + onChanged, + onNavigate, + }: { + conversation: RowConversation; + /** This is the conversation open on screen. */ + active?: boolean; + /** After any change, so the list can re-run a search that may include this chat. */ + onChanged?: () => void; + /** Called before leaving the page (after deleting the open chat) — closes the phone drawer. */ + onNavigate?: () => void; + } = $props(); + + let open = $state(false); + let renaming = $state(false); + let draftTitle = $state(''); + let busy = $state(false); + let errorMessage = $state<string | null>(null); + let actionsEl = $state<HTMLDivElement | null>(null); + let menuEl = $state<HTMLDivElement | null>(null); + let renameInput = $state<HTMLInputElement | null>(null); + + const pinned = $derived(conversation.pinnedAt !== null); + const archived = $derived(conversation.archivedAt !== null); + const exportBase = $derived(`/chat/${conversation.id}/export`); + + function close() { + open = false; + renaming = false; + errorMessage = null; + } + + function toggle() { + if (open) close(); + else { + open = true; + errorMessage = null; + } + } + + async function refreshLists() { + await Promise.all([ + getConversations().refresh().catch(() => {}), + getArchivedConversations().refresh().catch(() => {}), + ]); + onChanged?.(); + } + + async function run(action: () => Promise<unknown>, fallback: string): Promise<boolean> { + if (busy) return false; + busy = true; + errorMessage = null; + try { + await action(); + await refreshLists(); + return true; + } catch (err) { + errorMessage = remoteErrorMessage(err, fallback); + open = true; + return false; + } finally { + busy = false; + } + } + + async function toggleArchived() { + const done = await run( + () => setConversationArchived({ id: conversation.id, archived: !archived }), + archived ? 'Could not unarchive this chat.' : 'Could not archive this chat.', + ); + if (done) close(); + } + + async function togglePinned() { + const done = await run( + () => setConversationPinned({ id: conversation.id, pinned: !pinned }), + pinned ? 'Could not unpin this chat.' : 'Could not pin this chat.', + ); + if (done) close(); + } + + async function startRename() { + draftTitle = conversation.title; + renaming = true; + errorMessage = null; + await tick(); + renameInput?.focus(); + renameInput?.select(); + } + + async function saveRename(event?: SubmitEvent) { + event?.preventDefault(); + const title = draftTitle.trim(); + if (!title || title === conversation.title) { + close(); + return; + } + const done = await run(async () => { + await updateConversationMeta({ id: conversation.id, title: title.slice(0, 120) }); + if (active) await getConversation(conversation.id).refresh(); + }, 'Could not rename this chat.'); + if (done) close(); + } + + async function remove() { + const confirmed = await confirmDialog({ + title: 'Delete this conversation?', + message: + 'Its messages and run history are removed for good, and memories mined from it lose their link back to it. Cost records are kept. To tidy the list without losing anything, archive it instead.', + confirmLabel: 'Delete', + variant: 'danger', + }); + if (!confirmed) return; + const done = await run(() => deleteConversation(conversation.id), 'Could not delete this chat.'); + if (!done) return; + close(); + if (active) { + onNavigate?.(); + await goto('/'); + } + } + + /** + * A download link closes the menu only after the click has done its work: removing the + * link during its own click cancels the download (a disconnected link cannot navigate). + */ + function closeAfterClick() { + setTimeout(close, 0); + } + + // A press anywhere else closes the menu. Listened for only while open: the list has a + // menu per row, and a window listener each would be hundreds of idle handlers. + $effect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node | null; + if (!target || actionsEl?.contains(target) || menuEl?.contains(target)) return; + close(); + }; + window.addEventListener('pointerdown', onPointerDown); + return () => window.removeEventListener('pointerdown', onPointerDown); + }); + + function handleKeydown(event: KeyboardEvent) { + if (event.key === 'Escape' && open) { + event.stopPropagation(); + close(); + } + } +</script> + +<div class="console-rowactions {open ? 'is-open' : ''}" bind:this={actionsEl}> + <button + type="button" + class="console-rowactions__btn console-rowactions__quick" + title={archived ? 'Unarchive' : 'Archive'} + aria-label={`${archived ? 'Unarchive' : 'Archive'} ${conversation.title}`} + disabled={busy} + onclick={toggleArchived} + > + <Icon name="archive" size={13} /> + </button> + <button + type="button" + class="console-rowactions__btn" + title="Conversation actions" + aria-label={`Actions for ${conversation.title}`} + aria-haspopup="menu" + aria-expanded={open} + onclick={toggle} + > + <Icon name="dots" size={13} /> + </button> +</div> + +{#if open} + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="console-rowmenu" + role={renaming ? undefined : 'menu'} + aria-label={`Actions for ${conversation.title}`} + bind:this={menuEl} + onkeydown={handleKeydown} + > + {#if renaming} + <form class="console-rowmenu__rename" onsubmit={saveRename}> + <input + bind:this={renameInput} + bind:value={draftTitle} + type="text" + maxlength="120" + aria-label="Conversation title" + disabled={busy} + /> + <div class="console-rowmenu__renamebtns"> + <button type="button" class="console-rowmenu__item" onclick={close} disabled={busy}>Cancel</button> + <button type="submit" class="console-rowmenu__item is-primary" disabled={busy || !draftTitle.trim()}>Save</button> + </div> + </form> + {:else} + <button type="button" role="menuitem" class="console-rowmenu__item is-primary" disabled={busy} onclick={toggleArchived}> + <Icon name="archive" size={13} /> + <span>{archived ? 'Unarchive' : 'Archive'}</span> + </button> + <button type="button" role="menuitem" class="console-rowmenu__item" disabled={busy} onclick={togglePinned}> + <Icon name="pin" size={13} /> + <span>{pinned ? 'Unpin' : 'Pin to top'}</span> + </button> + <button type="button" role="menuitem" class="console-rowmenu__item" disabled={busy} onclick={startRename}> + <Icon name="edit" size={13} /> + <span>Rename</span> + </button> + <a role="menuitem" class="console-rowmenu__item" href={`${exportBase}?format=md`} download onclick={closeAfterClick}> + <Icon name="download" size={13} /> + <span>Export as Markdown</span> + </a> + <a role="menuitem" class="console-rowmenu__item" href={`${exportBase}?format=json`} download onclick={closeAfterClick}> + <Icon name="download" size={13} /> + <span>Export as JSON</span> + </a> + <div class="console-rowmenu__sep" role="separator"></div> + <button type="button" role="menuitem" class="console-rowmenu__item is-danger" disabled={busy} onclick={remove}> + <Icon name="trash" size={13} /> + <span>Delete…</span> + </button> + {/if} + {#if errorMessage} + <p class="console-rowmenu__error" role="alert">{errorMessage}</p> + {/if} + </div> +{/if} diff --git a/src/lib/chat-console/Icon.svelte b/src/lib/chat-console/Icon.svelte index d17c0443..1bdc0e88 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'; + | 'alert' | 'terminal' | 'grid' | 'globe' | 'external' + | 'pin' | 'archive' | 'download' | 'dots' | 'trash'; let { name, size = 14 }: { name: IconName; size?: number } = $props(); </script> @@ -70,4 +71,14 @@ <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"/></svg> {:else if name === 'external'} <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 4h6v6"/><path d="M20 4 11 13"/><path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"/></svg> +{:else if name === 'pin'} + <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg> +{:else if name === 'archive'} + <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg> +{:else if name === 'download'} + <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></svg> +{:else if name === 'dots'} + <svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" stroke="none" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg> +{:else if name === 'trash'} + <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg> {/if} diff --git a/src/lib/chat-console/console.css b/src/lib/chat-console/console.css index 60513113..9e746701 100644 --- a/src/lib/chat-console/console.css +++ b/src/lib/chat-console/console.css @@ -2616,3 +2616,242 @@ 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; + transition: opacity 100ms ease-out; +} + +.console-chatitem:hover .console-rowactions, +.console-chatitem:focus-within .console-rowactions, +.console-rowactions.is-open { + opacity: 1; +} + +.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; + 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); +} From 97542af724955bc050906e4ca7ce79dda67c40de Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 16:29:02 -0600 Subject: [PATCH 06/23] test(chat): pin the conversation lifecycle, search and export (#18) chat.message-search-text: what a message is indexed by (paths with their file name and segments, commands, links in output, short arguments) and what never is (file bodies, edit payloads, thinking, raw output, system messages), the 60k cap on a 5MB message, how a typed search becomes a text query, and that snippet highlights are split as text. chat.conversation-search: a tool call's file found by its full path, file name, partial path, segment and a prefix while typing, indexed by the write itself; archived chats only when asked; nothing for another user; stop-word searches still find titles; the backfill indexes what the writer missed and rebuilds rows from older rules; the sidebar shows the snippet with <mark> and the rest as text. chat.conversation-lifecycle: pinning unarchives, archiving unpins, updated_at never moves, the default list keeps every pin and leaves the archive out, a user's message unarchives and an automation's does not. In the sidebar: pin, rename, export, one-click archive, the Archived view and Unarchive; delete asks and leaves the open chat; on a phone the menu button shows without hover and the title keeps its width. chat.conversation-export: the Markdown and JSON formatters, fences that tool output cannot close, and the route's headers, 400, 404 and refusal without a session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/chat.conversation-export.spec.ts | 311 ++++++++++++++++++++ tests/chat.conversation-lifecycle.spec.ts | 338 ++++++++++++++++++++++ tests/chat.conversation-search.spec.ts | 277 ++++++++++++++++++ tests/chat.message-search-text.spec.ts | 248 ++++++++++++++++ 4 files changed, 1174 insertions(+) create mode 100644 tests/chat.conversation-export.spec.ts create mode 100644 tests/chat.conversation-lifecycle.spec.ts create mode 100644 tests/chat.conversation-search.spec.ts create mode 100644 tests/chat.message-search-text.spec.ts diff --git a/tests/chat.conversation-export.spec.ts b/tests/chat.conversation-export.spec.ts new file mode 100644 index 00000000..08372cd0 --- /dev/null +++ b/tests/chat.conversation-export.spec.ts @@ -0,0 +1,311 @@ +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 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.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..49b3293a --- /dev/null +++ b/tests/chat.conversation-lifecycle.spec.ts @@ -0,0 +1,338 @@ +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. + */ + +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() + await expect(setConversationPinnedForUser(stranger, id, true)).rejects.toThrow('Conversation not found') + await expect(setConversationArchivedForUser(stranger, id, true)).rejects.toThrow('Conversation not found') + 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 }) + 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) + } + }) +}) diff --git a/tests/chat.conversation-search.spec.ts b/tests/chat.conversation-search.spec.ts new file mode 100644 index 00000000..ea2aa452 --- /dev/null +++ b/tests/chat.conversation-search.spec.ts @@ -0,0 +1,277 @@ +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 + * - 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 + * - 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('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 + ` + + await backfillMessageSearch({ batchSize: 50 }) + 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 }) + 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('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..7077a771 --- /dev/null +++ b/tests/chat.message-search-text.spec.ts @@ -0,0 +1,248 @@ +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') + + 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([]) + }) +}) From a4ca4a4cc169bcd2f2a6b5e94dc107270b25a318 Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 16:30:39 -0600 Subject: [PATCH 07/23] docs(chat): the conversation lifecycle, search and export (#18) chat.md is the chat domain doc the folder never had, written for the owner: pin, archive (and what brings a chat back), rename, delete and what it removes versus keeps, how search works and what it does and does not index, and the two export formats. It corrects the re-audit's claim that delete takes the cost ledger with it: those rows stay and lose their run link. chat-console.md: how the sidebar shows it - the Pinned group, the row menu on pointer and touch devices, the Status views (the old "Archived" meant "not running"), and the search results. Parity: rename was scored even with no UI calling it; it has one now. Pin/archive is behind only for folders, search and export are even. The re-audit marks #18 shipped. README: feature paragraph, docs link and the export route. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 4 + docs/chat-console/chat-console.md | 25 +++ docs/chat/chat.md | 176 ++++++++++++++++++++ docs/structure/claude-desktop-parity.md | 8 +- docs/structure/parity-reaudit-2026-09-22.md | 18 +- 5 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 docs/chat/chat.md diff --git a/README.md b/README.md index 98db5495..6ece725a 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). + Replies can be read aloud: a speaker button on each reply plays it through an OpenRouter text-to-speech model (code blocks are skipped), and an opt-in, per-device **Auto-read** switch above the message box reads each new reply when its turn finishes, for hands-free use. The model and voice are set in Settings → Model & AI; spend is recorded in the usage ledger under "Read Aloud" and counts toward budget limits. See [docs/speech/speech.md](docs/speech/speech.md). Creation workflows are chat-led: New Agent and New Skill actions launch a fresh conversation with a seeded creation prompt. The assistant gathers missing requirements (optionally with ask_user), then executes directly with tool-level approvals where configured. @@ -204,6 +206,7 @@ Notes: - Master implementation order: `docs/structure/implementation-order.md` - Architecture refactor plan: `docs/structure/plan.md` - Runtime spec: `docs/runtime/spec.md` +- Chat (conversations: pin, archive, search, export): `docs/chat/chat.md` - Chat plan: `docs/chat/plan.md` - Memory spec: `docs/memory/spec.md` - Automations: `docs/automations/automations.md` @@ -262,6 +265,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 +- `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)) - `/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 - `/agents` Agent management diff --git a/docs/chat-console/chat-console.md b/docs/chat-console/chat-console.md index 86e0844f..108d6fcc 100644 --- a/docs/chat-console/chat-console.md +++ b/docs/chat-console/chat-console.md @@ -26,6 +26,31 @@ 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). +- **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. ("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 has four tabs. diff --git a/docs/chat/chat.md b/docs/chat/chat.md new file mode 100644 index 00000000..a4f19554 --- /dev/null +++ b/docs/chat/chat.md @@ -0,0 +1,176 @@ +# Chat + +## Overview + +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. + +This document covers what a conversation is and how the owner looks after the list of them: +pinning, archiving, renaming, deleting, searching and exporting. The live chat screen (the +composer, streaming, approvals, the right rail) is described in +[chat-console](../chat-console/chat-console.md) and, in older and partly out-of-date form, in +[spec.md](spec.md). + +Audience: the owner, and anyone working on the chat code (`src/lib/chat/`). + +## Key concepts + +| Concept | What it is | +| --- | --- | +| **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: from the user, the assistant, or the system (a note such as "the conversation switched agents here"). 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" below. Kept in its own table beside the messages. | + +## User flows + +All of these 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. + +### 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. +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. +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 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 a short + extract with the matching words highlighted. 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; +- 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 and the start of its answer; +- 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`). + +| 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, 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. | + +## 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. + +## Business rules + +- **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. +- **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. +- **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. +- **Search is kept up to date in the background.** A message is indexed right after it is + saved; 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. +- **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. + +## 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. + +## Where it lives + +| Piece | Location | +| --- | --- | +| Pin, archive and unarchive-on-reply rules | `src/lib/chat/conversation-lifecycle.server.ts` | +| The normal and archived lists | `src/lib/chat/conversation-list.server.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` | +| Data | `conversations.pinned_at`, `conversations.archived_at`, and the `message_search` table (migration `0079`) | diff --git a/docs/structure/claude-desktop-parity.md b/docs/structure/claude-desktop-parity.md index 3898e51b..61058dc1 100644 --- a/docs/structure/claude-desktop-parity.md +++ b/docs/structure/claude-desktop-parity.md @@ -84,10 +84,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) | right-rail Preview tab: markdown, highlighted code, images, PDFs, directory listing, sandboxed URL iframe; selection persists per conversation | 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, 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` | — | diff --git a/docs/structure/parity-reaudit-2026-09-22.md b/docs/structure/parity-reaudit-2026-09-22.md index 9aac9628..5f49d761 100644 --- a/docs/structure/parity-reaudit-2026-09-22.md +++ b/docs/structure/parity-reaudit-2026-09-22.md @@ -236,7 +236,7 @@ plan), **fold** (belongs inside another issue), **delete** (close it). | #32 | Multi-agent orchestration | **rebuild** | use SDK `agents` + the Task tool instead of a bespoke fan-out tool | | #5 | Port subagents to SDK subagents | **as filed** — shipped | keystone; `Options.agents` + `Task`, `run_subagent` retired | | #4 | Native AskUserQuestion | **as filed** | `toolConfig.askUserQuestion.previewFormat` confirmed present | -| #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** | build `@` now; `/` should wait for `settingSources` | | #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** | #29 already fixed the "blank by default" complaint; what is left is deleting two tabs | @@ -472,6 +472,20 @@ 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. 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. @@ -568,7 +582,7 @@ unless one of those is the actual goal. (#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**, **#18**, **#14**. +7. The cheap independents whenever: ~~**#27 delete**~~ (finished instead), **#4**, ~~**#18**~~ (shipped), **#14**. 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 From ff9ca17dcb3e4a6315b572db58cf7ca22c780a50 Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 16:32:03 -0600 Subject: [PATCH 08/23] fix(chat-console): key sidebar groups by day, and keep highlights unpadded Date groups were keyed by their label, and day labels drop the year, so a chat from the same date a year earlier gave two groups one key - a duplicate-key error in a keyed each. Groups are now keyed by the day or the category. The search snippet is rendered on one line so the markup's whitespace does not pad each highlighted word. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/lib/chat-console/ConsoleNavContent.svelte | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/lib/chat-console/ConsoleNavContent.svelte b/src/lib/chat-console/ConsoleNavContent.svelte index f781f5bd..dfec10a8 100644 --- a/src/lib/chat-console/ConsoleNavContent.svelte +++ b/src/lib/chat-console/ConsoleNavContent.svelte @@ -167,14 +167,16 @@ const grouped = $derived.by((): Group[] => { const pinned = showingArchive ? [] : sorted.filter((c) => c.pinnedAt); const rest = pinned.length > 0 ? sorted.filter((c) => !c.pinnedAt) : sorted; - const groups = groupConversations(rest, pinned.length > 0) - .filter(([, items]) => items.length > 0) - .map(([label, items]) => ({ key: `group:${label}`, label, items })); + const groups = groupConversations(rest, pinned.length > 0).filter((group) => group.items.length > 0); return pinned.length > 0 ? [{ key: 'pinned', label: 'Pinned', items: pinned }, ...groups] : groups; }); - function groupConversations(list: Conversation[], labelFlat: boolean): Array<[string, Conversation[]]> { - if (groupBy === 'None') return [[labelFlat ? 'Recent' : '', list]]; + /* + * Keyed by the day or the category rather than the label: day labels drop the year, so + * the same date a year apart used to produce two groups with one key. + */ + function groupConversations(list: Conversation[], labelFlat: boolean): Group[] { + if (groupBy === 'None') return [{ key: 'group:flat', label: labelFlat ? 'Recent' : '', items: list }]; if (groupBy === 'Date') { const m = new Map<string, { label: string; ts: number; items: Conversation[] }>(); for (const c of list) { @@ -187,9 +189,9 @@ 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[]]); + return [...m.entries()] + .sort(([, a], [, b]) => b.ts - a.ts) + .map(([key, g]) => ({ key: `day:${key}`, label: g.label, items: g.items })); } const fieldKey: keyof Conversation = groupBy === 'Project' ? 'category' : groupBy === 'Status' ? 'category' : 'category'; const m = new Map<string, Conversation[]>(); @@ -198,7 +200,7 @@ if (!m.has(key)) m.set(key, []); m.get(key)!.push(c); } - return [...m.entries()]; + return [...m.entries()].map(([key, items]) => ({ key: `category:${key}`, label: key, items })); } /* @@ -444,11 +446,8 @@ <time class="s" datetime={when.toISOString()} title={when.toLocaleString()}>{relativeShort(when)}</time> </span> {#if hit.match} - <span class="console-searchhit__snippet"> - {#each splitSnippet(hit.match.snippet) as part, i (i)} - {#if part.mark}<mark>{part.text}</mark>{:else}{part.text}{/if} - {/each} - </span> + <!-- One line on purpose: whitespace in the markup would pad every highlight. --> + <span class="console-searchhit__snippet">{#each splitSnippet(hit.match.snippet) as part, i (i)}{#if part.mark}<mark>{part.text}</mark>{:else}{part.text}{/if}{/each}</span> {/if} </a> {/each} From bb9bef65300233b15559a6ae9746f4e578682ed8 Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 16:32:41 -0600 Subject: [PATCH 09/23] fix(chat-console): a row's hidden actions cannot be clicked On a pointer device the row's Archive and menu buttons are invisible until the row is hovered or focused, but they still sat over the time label and took clicks. A tap there on a touch-screen laptop, whose primary pointer reports hover, archived the chat without showing why. Hidden now also means pointer-events: none; hovering the row turns them back on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/lib/chat-console/console.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/chat-console/console.css b/src/lib/chat-console/console.css index 9e746701..6dab3d34 100644 --- a/src/lib/chat-console/console.css +++ b/src/lib/chat-console/console.css @@ -2647,6 +2647,8 @@ a.console-crumbs__seg:hover { 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; } @@ -2654,6 +2656,7 @@ a.console-crumbs__seg:hover { .console-chatitem:focus-within .console-rowactions, .console-rowactions.is-open { opacity: 1; + pointer-events: auto; } .console-rowactions__btn { @@ -2682,6 +2685,7 @@ a.console-crumbs__seg:hover { .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. */ From d54eaf748006694b57089387976719d6989757ef Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 16:33:45 -0600 Subject: [PATCH 10/23] fix(chat-console): one empty message per search, not two With a search running on the server, an empty local list said "No chats match." while the results section below said "Nothing found in messages." The local message now shows only for searches too short to send, and the results section speaks for the rest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/lib/chat-console/ConsoleNavContent.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/chat-console/ConsoleNavContent.svelte b/src/lib/chat-console/ConsoleNavContent.svelte index dfec10a8..65af50b3 100644 --- a/src/lib/chat-console/ConsoleNavContent.svelte +++ b/src/lib/chat-console/ConsoleNavContent.svelte @@ -416,7 +416,8 @@ {/each} {#if conversations.length === 0 && messageHits.length === 0} <div class="console-chatempty">{showingArchive ? 'Nothing archived.' : 'No conversations yet'}</div> - {:else if sorted.length === 0 && messageHits.length === 0 && searchStatus !== 'loading'} + {:else if sorted.length === 0 && searchStatus === 'idle'} + <!-- Once the server search runs, its own section says whether anything matched. --> <div class="console-chatempty">No chats match.</div> {/if} From 9df0c8277ade7ac2449a91a38b0b79cccba24f4b Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 16:34:45 -0600 Subject: [PATCH 11/23] fix(chat-console): Escape on the row's menu button closes the menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Escape was handled only inside the open menu, so with focus still on the "⋯" button it went to the phone drawer and closed that instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/lib/chat-console/ConversationRowMenu.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/chat-console/ConversationRowMenu.svelte b/src/lib/chat-console/ConversationRowMenu.svelte index 2bba91eb..3cda4329 100644 --- a/src/lib/chat-console/ConversationRowMenu.svelte +++ b/src/lib/chat-console/ConversationRowMenu.svelte @@ -185,7 +185,9 @@ } </script> -<div class="console-rowactions {open ? 'is-open' : ''}" bind:this={actionsEl}> +<!-- Escape on the "⋯" button closes the menu too, before the drawer hears it. --> +<!-- svelte-ignore a11y_no_static_element_interactions --> +<div class="console-rowactions {open ? 'is-open' : ''}" bind:this={actionsEl} onkeydown={handleKeydown}> <button type="button" class="console-rowactions__btn console-rowactions__quick" From b211be178a166de546edc559d8126391e8c39700 Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 20:15:35 -0600 Subject: [PATCH 12/23] fix(chat): search snippets keep the words around a match (#18) ts_headline's excerpt mode will not start or end an excerpt on a word of three letters or fewer. When the excerpt reached either end of a short message it trimmed those words away, down to the match itself: "fix the login bug now" came back as just "login", and "Deploy it" as "Deploy". Short messages are what people search for most. A message of up to 100 characters is now shown whole, with the matches marked. Longer ones keep two excerpts around the matches, with ShortWord=0 so their edges are kept too. The spec pins both, and the sidebar spec's "R&D <3 word now" extract now holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/lib/chat/message-search-sql.ts | 32 +++++++++++++++-- tests/chat.conversation-search.spec.ts | 49 ++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/lib/chat/message-search-sql.ts b/src/lib/chat/message-search-sql.ts index c9af2a41..5515415d 100644 --- a/src/lib/chat/message-search-sql.ts +++ b/src/lib/chat/message-search-sql.ts @@ -78,7 +78,35 @@ export function backfillBatchQuery(cursor: string | null, batchSize: number): SQ ` } -export const HEADLINE_OPTIONS = `StartSel="${SNIPPET_START}", StopSel="${SNIPPET_STOP}", MaxFragments=2, MaxWords=18, MinWords=6, FragmentDelimiter=" … "` +/** + * 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 { @@ -139,7 +167,7 @@ export function contentSearchQuery(input: { userId: string; tsq: SQL; includeArc c.archived_at, m.role, m.created_at as message_created_at, - ts_headline('english', ms.body, ${tsq}, ${HEADLINE_OPTIONS}) as snippet + ${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 diff --git a/tests/chat.conversation-search.spec.ts b/tests/chat.conversation-search.spec.ts index ea2aa452..8b2f76d6 100644 --- a/tests/chat.conversation-search.spec.ts +++ b/tests/chat.conversation-search.spec.ts @@ -12,6 +12,8 @@ import { SNIPPET_START, SNIPPET_STOP } from '../src/lib/chat/conversation-search * - 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 @@ -109,6 +111,53 @@ test.describe('conversation search', () => { } }) + 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() From ad43641c4c5c3b728f215063ba0056769a489f0b Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 20:15:44 -0600 Subject: [PATCH 13/23] fix(chat): the search backfill skips a message deleted mid-batch (#18) A backfill batch was one multi-row upsert. A message deleted between the batch's read and its write (its conversation deleted meanwhile) failed the whole statement on its foreign key, and the boot backfill stopped there until the next restart; on a first deploy that is the whole history. The batch now falls back to one row at a time on a foreign-key violation and skips the rows that are gone. The backfill also takes an optional conversation, which the spec uses so it no longer indexes other workers' rows while they are being inserted and deleted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/lib/chat/message-search-sql.ts | 14 +++++--- src/lib/chat/message-search.server.ts | 46 +++++++++++++++++++++++--- tests/chat.conversation-search.spec.ts | 38 +++++++++++++++++++-- 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/src/lib/chat/message-search-sql.ts b/src/lib/chat/message-search-sql.ts index 5515415d..35a945b2 100644 --- a/src/lib/chat/message-search-sql.ts +++ b/src/lib/chat/message-search-sql.ts @@ -57,10 +57,16 @@ export type BackfillRow = { /** * The next `batchSize` messages after `cursor` (by id) that have no search row, or one built - * by an older `SEARCH_BUILDER_VERSION`. + * by an older `SEARCH_BUILDER_VERSION`. Only one conversation's when `conversationId` is given. */ -export function backfillBatchQuery(cursor: string | null, batchSize: number): SQL { - const after: SQL = cursor ? sql`and m.id > ${cursor}::uuid` : sql`` +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, @@ -72,7 +78,7 @@ export function backfillBatchQuery(cursor: string | null, batchSize: number): SQ ${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}) ${after} + where (ms.message_id is null or ms.builder_version < ${SEARCH_BUILDER_VERSION}) ${scope} order by m.id limit ${batchSize} ` diff --git a/src/lib/chat/message-search.server.ts b/src/lib/chat/message-search.server.ts index 866e1d4d..cbb38bf8 100644 --- a/src/lib/chat/message-search.server.ts +++ b/src/lib/chat/message-search.server.ts @@ -15,6 +15,7 @@ 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' @@ -88,13 +89,47 @@ export function scheduleMessageIndex(row: IndexableMessage | null | undefined): }) } +/** 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 and in several processes at once: each write is an upsert. + * 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 } = {}): Promise<{ +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)) @@ -103,10 +138,12 @@ export async function backfillMessageSearch(options: { batchSize?: number; maxBa let indexed = 0 for (let batch = 0; batch < maxBatches; batch += 1) { - const rows: BackfillRow[] = await db.execute<BackfillRow>(backfillBatchQuery(cursor, batchSize)) + const rows: BackfillRow[] = await db.execute<BackfillRow>( + backfillBatchQuery(cursor, batchSize, options.conversationId), + ) if (rows.length === 0) break - await indexMessages( + indexed += await indexMessagesSkippingGone( rows.map((row) => ({ id: row.id, conversationId: row.conversation_id, @@ -117,7 +154,6 @@ export async function backfillMessageSearch(options: { batchSize?: number; maxBa toolCalls: row.tool_calls, })), ) - indexed += rows.length cursor = rows[rows.length - 1].id if (rows.length < batchSize) break } diff --git a/tests/chat.conversation-search.spec.ts b/tests/chat.conversation-search.spec.ts index 8b2f76d6..c29affa8 100644 --- a/tests/chat.conversation-search.spec.ts +++ b/tests/chat.conversation-search.spec.ts @@ -17,7 +17,7 @@ import { SNIPPET_START, SNIPPET_STOP } from '../src/lib/chat/conversation-search * - 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 + * 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 @@ -233,7 +233,8 @@ test.describe('conversation search', () => { returning id ` - await backfillMessageSearch({ batchSize: 50 }) + // 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`) @@ -242,7 +243,7 @@ test.describe('conversation search', () => { // 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 }) + await backfillMessageSearch({ batchSize: 50, conversationId }) const rebuilt = await searchRowFor(missed.id) expect(rebuilt?.builder_version).toBe(SEARCH_BUILDER_VERSION) expect(rebuilt?.body).toContain(word) @@ -255,6 +256,37 @@ test.describe('conversation search', () => { } }) + 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() From 31d91e36c37f13d701a0ef93bfb2f9e604cff8bf Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 20:15:44 -0600 Subject: [PATCH 14/23] fix(chat): export file names never split an emoji (#18) The readable file name was cut at 80 UTF-16 units. An emoji across the cut left half a surrogate pair, encodeURIComponent threw on it, and the download answered 500. The name is now cut by code points, and a lone half already in a title is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/lib/chat/conversation-export.ts | 25 +++++++++++++++++++------ tests/chat.conversation-export.spec.ts | 14 ++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/lib/chat/conversation-export.ts b/src/lib/chat/conversation-export.ts index bb90cbcf..72040f49 100644 --- a/src/lib/chat/conversation-export.ts +++ b/src/lib/chat/conversation-export.ts @@ -375,15 +375,28 @@ export function exportFileNames(title: string, date: Date, extension: 'md' | 'js .slice(0, 60) .replace(/-+$/g, '') || 'conversation' const readable = - title - .replace(/[\u0000-\u001f\u007f/\\:*?"<>|]+/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 80) - .trim() || 'conversation' + 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()}`) diff --git a/tests/chat.conversation-export.spec.ts b/tests/chat.conversation-export.spec.ts index 08372cd0..edd99b6b 100644 --- a/tests/chat.conversation-export.spec.ts +++ b/tests/chat.conversation-export.spec.ts @@ -212,6 +212,20 @@ test.describe('conversation export — the formatter', () => { `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', () => { From c348fa49f3bbe97e76cad9610a4ad1d069cc40ef Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 20:15:59 -0600 Subject: [PATCH 15/23] fix(chat-console): pinned chats by pin time, the archive by archive time (#18) The server lists pinned chats by when they were pinned and the archive by when each chat was archived, but the sidebar re-sorted every list by last activity and threw that order away. The Pinned group followed activity, and the Archived view was ordered, grouped by day and dated by last use rather than by when each chat was put away. The sorting and grouping move to $lib/chat/conversation-order, a plain module a spec can load without a browser: pins by pin time, the archive by archive time (each row's time is when it was archived there), the rest by last activity. Sort by Name or Project still applies in every group. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/lib/chat-console/ConsoleNavContent.svelte | 65 +------ src/lib/chat/conversation-order.ts | 137 +++++++++++++++ tests/chat.conversation-order.spec.ts | 160 ++++++++++++++++++ 3 files changed, 304 insertions(+), 58 deletions(-) create mode 100644 src/lib/chat/conversation-order.ts create mode 100644 tests/chat.conversation-order.spec.ts diff --git a/src/lib/chat-console/ConsoleNavContent.svelte b/src/lib/chat-console/ConsoleNavContent.svelte index 65af50b3..4840a5d6 100644 --- a/src/lib/chat-console/ConsoleNavContent.svelte +++ b/src/lib/chat-console/ConsoleNavContent.svelte @@ -10,7 +10,7 @@ import { getCredits, refreshCredits } from '$lib/llm/credits.remote'; import Icon from './Icon.svelte'; import ConversationRowMenu from './ConversationRowMenu.svelte'; - import { dayKey, dayLabel } from '$lib/util/relative-time'; + import { groupConversations, listTime, type ConversationListView } from '$lib/chat/conversation-order'; const THEME_STORAGE_KEY = 'AgentStudio-theme'; let isDark = $state(true); @@ -145,63 +145,12 @@ }); }); - 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); - }); - } - return arr.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); - }); - - type Group = { key: string; label: string; items: Conversation[] }; - - /** - * Pinned chats (#18) form their own group above the others whatever the grouping, and are - * left out of the groups below. The archive has no pinned group: archiving unpins. - */ - const grouped = $derived.by((): Group[] => { - const pinned = showingArchive ? [] : sorted.filter((c) => c.pinnedAt); - const rest = pinned.length > 0 ? sorted.filter((c) => !c.pinnedAt) : sorted; - const groups = groupConversations(rest, pinned.length > 0).filter((group) => group.items.length > 0); - return pinned.length > 0 ? [{ key: 'pinned', label: 'Pinned', items: pinned }, ...groups] : groups; - }); - /* - * Keyed by the day or the category rather than the label: day labels drop the year, so - * the same date a year apart used to produce two groups with one key. + * #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. */ - function groupConversations(list: Conversation[], labelFlat: boolean): Group[] { - if (groupBy === 'None') return [{ key: 'group:flat', label: labelFlat ? 'Recent' : '', items: list }]; - if (groupBy === 'Date') { - const m = new Map<string, { label: string; ts: number; items: Conversation[] }>(); - for (const c of list) { - 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.entries()] - .sort(([, a], [, b]) => b.ts - a.ts) - .map(([key, g]) => ({ key: `day:${key}`, label: g.label, items: g.items })); - } - const fieldKey: keyof Conversation = groupBy === 'Project' ? 'category' : groupBy === 'Status' ? 'category' : 'category'; - const m = new Map<string, Conversation[]>(); - for (const c of list) { - const key = (c[fieldKey] as string | null) ?? 'Uncategorized'; - if (!m.has(key)) m.set(key, []); - m.get(key)!.push(c); - } - return [...m.entries()].map(([key, items]) => ({ key: `category:${key}`, label: key, items })); - } + const listView = $derived<ConversationListView>(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 @@ -403,7 +352,7 @@ {/if} </span> <span class="t">{conversation.title}</span> - <span class="s">· {relativeShort(conversation.updatedAt)}</span> + <span class="s">· {relativeShort(listTime(conversation, listView))}</span> </a> <ConversationRowMenu {conversation} @@ -416,7 +365,7 @@ {/each} {#if conversations.length === 0 && messageHits.length === 0} <div class="console-chatempty">{showingArchive ? 'Nothing archived.' : 'No conversations yet'}</div> - {:else if sorted.length === 0 && searchStatus === 'idle'} + {:else if filtered.length === 0 && searchStatus === 'idle'} <!-- Once the server search runs, its own section says whether anything matched. --> <div class="console-chatempty">No chats match.</div> {/if} 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/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) + }) +}) From bf3751826d109fe6d50ff8ca6cb1ca8f990481f9 Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 20:15:59 -0600 Subject: [PATCH 16/23] fix(chat-console): the archive loads before it says it is empty (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the Archived view said "Nothing archived." while its list was still on its way; it now says "Loading…" until the list arrives. And the row menu refetched the archive after every change, archive open or not; it now does so only when the chat is in the archive or is going into it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/lib/chat-console/ConsoleNavContent.svelte | 6 +++++- src/lib/chat-console/ConversationRowMenu.svelte | 10 ++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/lib/chat-console/ConsoleNavContent.svelte b/src/lib/chat-console/ConsoleNavContent.svelte index 4840a5d6..6151e8fa 100644 --- a/src/lib/chat-console/ConsoleNavContent.svelte +++ b/src/lib/chat-console/ConsoleNavContent.svelte @@ -100,6 +100,8 @@ const conversations = $derived<Conversation[]>( (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; @@ -363,7 +365,9 @@ </div> {/each} {/each} - {#if conversations.length === 0 && messageHits.length === 0} + {#if archiveLoading} + <div class="console-chatempty">Loading…</div> + {:else if conversations.length === 0 && messageHits.length === 0} <div class="console-chatempty">{showingArchive ? 'Nothing archived.' : 'No conversations yet'}</div> {:else if filtered.length === 0 && searchStatus === 'idle'} <!-- Once the server search runs, its own section says whether anything matched. --> diff --git a/src/lib/chat-console/ConversationRowMenu.svelte b/src/lib/chat-console/ConversationRowMenu.svelte index 3cda4329..b3abb903 100644 --- a/src/lib/chat-console/ConversationRowMenu.svelte +++ b/src/lib/chat-console/ConversationRowMenu.svelte @@ -74,21 +74,22 @@ } } - async function refreshLists() { + /** The archive is refetched only when this chat is in it, or is going into it: nothing else moves it. */ + async function refreshLists(archiveToo: boolean) { await Promise.all([ getConversations().refresh().catch(() => {}), - getArchivedConversations().refresh().catch(() => {}), + archiveToo ? getArchivedConversations().refresh().catch(() => {}) : null, ]); onChanged?.(); } - async function run(action: () => Promise<unknown>, fallback: string): Promise<boolean> { + async function run(action: () => Promise<unknown>, fallback: string, touchesArchive = archived): Promise<boolean> { if (busy) return false; busy = true; errorMessage = null; try { await action(); - await refreshLists(); + await refreshLists(touchesArchive); return true; } catch (err) { errorMessage = remoteErrorMessage(err, fallback); @@ -103,6 +104,7 @@ const done = await run( () => setConversationArchived({ id: conversation.id, archived: !archived }), archived ? 'Could not unarchive this chat.' : 'Could not archive this chat.', + true, ); if (done) close(); } From 9ab8d7f3462d039d1d30b08b16b5110ebe4bd5f7 Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 20:16:12 -0600 Subject: [PATCH 17/23] fix(chat): a phone's "Recent chats" are the latest, not the pinned (#18) The home page shows the first five chats of the list on a phone, where the sidebar is hidden. The list now starts with every pinned chat, so someone with five pins saw only those, however old, and never their latest chat. The five are now picked by last activity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/routes/+page.svelte | 5 +++- tests/chat.conversation-lifecycle.spec.ts | 31 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) 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/tests/chat.conversation-lifecycle.spec.ts b/tests/chat.conversation-lifecycle.spec.ts index 49b3293a..4de8ce38 100644 --- a/tests/chat.conversation-lifecycle.spec.ts +++ b/tests/chat.conversation-lifecycle.spec.ts @@ -19,6 +19,8 @@ import { * 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) { @@ -335,4 +337,33 @@ test.describe('conversation lifecycle — the sidebar', () => { 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) + } + }) }) From dafcb42f147667c8c78144d0223fa1a400a8cd89 Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 20:17:28 -0600 Subject: [PATCH 18/23] fix(chat): pin and archive answer 404 for a chat that is not yours (#18) setConversationPinnedForUser and setConversationArchivedForUser threw a plain Error for a conversation the caller does not own, which surfaced as a 500 in the server log. They now return null, and the remote functions answer 404. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/lib/chat/chat.remote.ts | 3 +++ src/lib/chat/conversation-lifecycle.server.ts | 12 ++++++------ tests/chat.conversation-lifecycle.spec.ts | 5 +++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/lib/chat/chat.remote.ts b/src/lib/chat/chat.remote.ts index 246f96b6..f257200b 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, gt, isNull, ne, or, sql } from 'drizzle-orm' import { z } from 'zod' import { db } from '$lib/db.server' @@ -440,6 +441,7 @@ const setConversationPinnedSchema = z.object({ id: z.string().uuid(), pinned: z. 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 } }) @@ -452,6 +454,7 @@ const setConversationArchivedSchema = z.object({ id: z.string().uuid(), archived 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 } }) diff --git a/src/lib/chat/conversation-lifecycle.server.ts b/src/lib/chat/conversation-lifecycle.server.ts index 915ab993..f264902d 100644 --- a/src/lib/chat/conversation-lifecycle.server.ts +++ b/src/lib/chat/conversation-lifecycle.server.ts @@ -32,32 +32,32 @@ const lifecycleColumns = { 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> { +): 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) - if (!row) throw new Error('Conversation not found') - return row + 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> { +): 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) - if (!row) throw new Error('Conversation not found') - return row + return row ?? null } /** diff --git a/tests/chat.conversation-lifecycle.spec.ts b/tests/chat.conversation-lifecycle.spec.ts index 4de8ce38..bf12a6c8 100644 --- a/tests/chat.conversation-lifecycle.spec.ts +++ b/tests/chat.conversation-lifecycle.spec.ts @@ -90,8 +90,9 @@ test.describe('conversation lifecycle — the rules', () => { try { const id = await seedConversation(`${prefix} mine`, userId) const stranger = randomUUID() - await expect(setConversationPinnedForUser(stranger, id, true)).rejects.toThrow('Conversation not found') - await expect(setConversationArchivedForUser(stranger, id, true)).rejects.toThrow('Conversation not found') + // 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() From 1821d935ea79b9dd143c5240065bf594577e355c Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 20:17:36 -0600 Subject: [PATCH 19/23] fix(chat): deleting a conversation stops its running turn first (#18) The delete cascades to the conversation's chat_runs rows, and Stop finds a run through its row. Deleting under a live turn left the agent running tools with nothing able to stop it, until its final write failed on a conversation that no longer existed. deleteConversationForUser checks ownership, interrupts the conversation's unfinished runs through the run registry (as Stop does), waits up to ten seconds for them to wind down, and then deletes. Another user's delete touches nothing. A turn held by the jobs worker is out of reach here, exactly as it is for Stop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/lib/chat/chat.remote.ts | 4 +- src/lib/chat/conversation-delete.server.ts | 110 ++++++++++++ tests/chat.conversation-delete.spec.ts | 188 +++++++++++++++++++++ 3 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 src/lib/chat/conversation-delete.server.ts create mode 100644 tests/chat.conversation-delete.spec.ts diff --git a/src/lib/chat/chat.remote.ts b/src/lib/chat/chat.remote.ts index f257200b..a130b329 100644 --- a/src/lib/chat/chat.remote.ts +++ b/src/lib/chat/chat.remote.ts @@ -19,6 +19,7 @@ import { BUILTIN_AGENT_KEYS } from '$lib/agents/builtin-agents.server' import { insertMessageWithSequence } from '$lib/chat/insert-message.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 { scheduleMessageIndex, 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' @@ -141,9 +142,10 @@ export const createConversation = command(createConversationSchema, async (input return created }) +/** #18 — stops the conversation's live turn first; see `deleteConversationForUser`. */ export const deleteConversation = command(conversationIdSchema, async (conversationId) => { const user = requireAuthenticatedRequestUser() - await db.delete(conversations).where(and(eq(conversations.id, conversationId), eq(conversations.userId, user.id))) + await deleteConversationForUser(user.id, conversationId) return { success: true } }) diff --git a/src/lib/chat/conversation-delete.server.ts b/src/lib/chat/conversation-delete.server.ts new file mode 100644 index 00000000..7ba17930 --- /dev/null +++ b/src/lib/chat/conversation-delete.server.ts @@ -0,0 +1,110 @@ +/** + * Deleting a conversation (#18) — the irreversible end of its lifecycle. + * + * 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. 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), and + * 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. + */ + +import { and, eq, isNull } from 'drizzle-orm' +import { db } from '$lib/db.server' +import { conversations } from '$lib/sessions/sessions.schema' +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 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. + * + * `settleMs` and `pollMs` exist for the spec; the remote function passes neither. + */ +export async function deleteConversationForUser( + userId: string, + conversationId: string, + options: { settleMs?: number; pollMs?: number } = {}, +): Promise<DeleteConversationResult> { + 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, 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<boolean> { + 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/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) + } + }) +}) From f242821d27d346fab89b4c36073f8790900e9251 Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 20:17:36 -0600 Subject: [PATCH 20/23] test(chat): keep the unarchived chat among the recent ones (#18) The sidebar spec expects a chat back in the list after Unarchive. The list holds the 50 most recently active chats, and the chat was created about 30 seconds earlier while other workers seed chats for the same owner, so it could fall out of the list. It now has fresh activity before Unarchive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/chat.conversation-lifecycle.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/chat.conversation-lifecycle.spec.ts b/tests/chat.conversation-lifecycle.spec.ts index bf12a6c8..ef225764 100644 --- a/tests/chat.conversation-lifecycle.spec.ts +++ b/tests/chat.conversation-lifecycle.spec.ts @@ -264,6 +264,9 @@ test.describe('conversation lifecycle — the sidebar', () => { 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() From e5f908e459057a865ffdecb3a7ab45941c1511cb Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 20:17:43 -0600 Subject: [PATCH 21/23] docs(chat): pin and archive order, delete under a running turn, extracts (#18) The chat doc now says the Pinned group goes by pin time and the archive by archive time, that a delete stops a running turn first and waits up to ten seconds, how long an extract is, how export file names are cut, that a phone's "Recent chats" ignore pins, and that the backfill skips a message deleted under it. The console doc covers the sidebar's side: pin order, the archive's order and its loading state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/chat-console/chat-console.md | 9 ++++--- docs/chat/chat.md | 40 ++++++++++++++++++++++++------- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/docs/chat-console/chat-console.md b/docs/chat-console/chat-console.md index 108d6fcc..1a4aa012 100644 --- a/docs/chat-console/chat-console.md +++ b/docs/chat-console/chat-console.md @@ -32,7 +32,8 @@ The rules behind these are in the [chat domain doc](../chat/chat.md); this is ho sidebar presents them. - **Pinned chats** sit in their own **Pinned** group above everything else, whichever way - the list is grouped (date, project, none). + 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 @@ -42,8 +43,10 @@ sidebar presents them. 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. ("Archived" used to mean "not - running", and "Active" meant running; both were renamed when the real archive arrived.) + 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 diff --git a/docs/chat/chat.md b/docs/chat/chat.md index a4f19554..d9621c1b 100644 --- a/docs/chat/chat.md +++ b/docs/chat/chat.md @@ -37,7 +37,8 @@ opens just below the row. 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. + 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) @@ -47,7 +48,10 @@ opens just below the row. 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. + **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 @@ -63,7 +67,10 @@ example, by opening it from a search result and replying). 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 that conversation was open, the app goes to the new-chat page. +3. If the agent is still working in that conversation, its turn is stopped first, exactly + as the **Stop** button would stop it, 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 @@ -72,8 +79,10 @@ example, by opening it from a search result and replying). 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 a short - extract with the matching words highlighted. Click one to open that conversation. + *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: @@ -102,7 +111,9 @@ thinking, and system notes. 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`). + `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 | | --- | --- | --- | @@ -128,7 +139,12 @@ conversations: - **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. + 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. @@ -138,6 +154,11 @@ conversations: - **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. 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. - **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 @@ -147,7 +168,8 @@ conversations: saved; 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. + way. A message deleted while this catch-up is running is simply skipped; it does not stop + the rest from being indexed. - **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. @@ -166,7 +188,9 @@ conversations: | 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` | From c542668d326e23aa02c852a84a97e7e0c087b913 Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 21:56:24 -0600 Subject: [PATCH 22/23] fix(chat): search and export read a delegated child's card as #32 writes it (#18, #32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #18 found delegated work through the delegation's own Task/Agent tool block (its prompt and subagent_type are indexed arguments) plus the subagent block's task and text. #32 dropped that tool block, so the card is now the delegation's only record, and it keeps the child's own calls on `transcript`, each labelled with the path, command or pattern it touched. Neither search nor the Markdown export read them, so a turn whose edit was delegated could not be found by the file edited, and a child the user stopped was exported with a failure cross. - message-search-text: a subagent line also carries the child's distinct tool calls with their labels (paths spelled out as segments), and its final report when it said nothing on the way. SEARCH_BUILDER_VERSION 2, so the boot backfill rebuilds older rows. - conversation-export: the card lists the child's calls, uses the report as the body when there is no text, reads `stopped` as stopped (not ✗) and keeps the reason. A pre-#32 card reads as before. - specs for both; docs (chat.md, the parity pages) say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/chat/chat.md | 6 ++- docs/structure/claude-desktop-parity.md | 2 +- docs/structure/parity-reaudit-2026-09-22.md | 4 +- src/lib/chat/conversation-export.ts | 44 ++++++++++++++--- src/lib/chat/message-search-text.ts | 37 ++++++++++++-- tests/chat.conversation-export.spec.ts | 54 +++++++++++++++++++++ tests/chat.message-search-text.spec.ts | 33 +++++++++++++ 7 files changed, 166 insertions(+), 14 deletions(-) diff --git a/docs/chat/chat.md b/docs/chat/chat.md index a6b3ce4e..5b3bfd03 100644 --- a/docs/chat/chat.md +++ b/docs/chat/chat.md @@ -126,7 +126,9 @@ What search finds: 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 and the start of its answer; +- 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 @@ -149,7 +151,7 @@ thinking, and system notes. | 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, the model's thinking folded away. Very long tool output is shortened, with a note pointing to the JSON export. | +| **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 diff --git a/docs/structure/claude-desktop-parity.md b/docs/structure/claude-desktop-parity.md index 68dbb8a4..8d81c8ee 100644 --- a/docs/structure/claude-desktop-parity.md +++ b/docs/structure/claude-desktop-parity.md @@ -88,7 +88,7 @@ This is the Cowork comparison, and it is the one I got wrong in the first draft: | Inline charts and visualizations | **absent** | markdown only | interactive charts, diagrams, Claude Design | | 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, 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 | +| 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 | diff --git a/docs/structure/parity-reaudit-2026-09-22.md b/docs/structure/parity-reaudit-2026-09-22.md index 7aa92861..0d611d5a 100644 --- a/docs/structure/parity-reaudit-2026-09-22.md +++ b/docs/structure/parity-reaudit-2026-09-22.md @@ -697,7 +697,9 @@ opinion above: deleting a conversation does not take its cost ledger with it — 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. Export is Markdown plus a complete +(#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 diff --git a/src/lib/chat/conversation-export.ts b/src/lib/chat/conversation-export.ts index 72040f49..4b249eb8 100644 --- a/src/lib/chat/conversation-export.ts +++ b/src/lib/chat/conversation-export.ts @@ -218,6 +218,41 @@ function toolMarkdown(block: Record<string, unknown>): string { 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, unknown>): 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<string, unknown> => 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, unknown>): string { switch (block.kind) { case 'text': @@ -226,13 +261,8 @@ function blockMarkdown(block: Record<string, unknown>): string { const content = str(block.content).trim() return content ? `<details><summary>Thinking</summary>\n\n${content}\n\n</details>` : '' } - case 'subagent': { - const status = block.success === false || block.status === 'failed' ? ' ✗' : '' - const task = str(block.task).trim() - const body = str(block.content).trim() - const head = `**Subagent · ${str(block.agentName) || 'subagent'}**${status}${task ? ` — ${task.replace(/\s*\n\s*/g, ' ')}` : ''}` - return quote(body ? `${head}\n\n${body}` : head) - } + case 'subagent': + return subagentMarkdown(block) case 'notice': { const notice = asRecord(block.notice) const title = str(notice?.title).trim() diff --git a/src/lib/chat/message-search-text.ts b/src/lib/chat/message-search-text.ts index c3cda1dd..1e0bf726 100644 --- a/src/lib/chat/message-search-text.ts +++ b/src/lib/chat/message-search-text.ts @@ -33,8 +33,11 @@ /** * 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 = 1 +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 @@ -49,6 +52,8 @@ const ARGUMENT_MAX_CHARS = 2_000 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. */ @@ -219,11 +224,37 @@ function toolLine(item: Record<string, unknown>): string { 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 content = typeof item.content === 'string' ? clip(item.content, SUBAGENT_MAX_CHARS) : '' - return [`Subagent ${name}:`, task, content].filter(Boolean).join(' ') + 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`). */ diff --git a/tests/chat.conversation-export.spec.ts b/tests/chat.conversation-export.spec.ts index edd99b6b..f938d52a 100644 --- a/tests/chat.conversation-export.spec.ts +++ b/tests/chat.conversation-export.spec.ts @@ -141,6 +141,60 @@ test.describe('conversation export — the formatter', () => { 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') diff --git a/tests/chat.message-search-text.spec.ts b/tests/chat.message-search-text.spec.ts index 7077a771..a03f99a0 100644 --- a/tests/chat.message-search-text.spec.ts +++ b/tests/chat.message-search-text.spec.ts @@ -141,6 +141,39 @@ test.describe('buildMessageSearchText — what is indexed', () => { }) 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', From d616235ba7d8bd15af2e100aacefb165415b2a21 Mon Sep 17 00:00:00 2001 From: Derek Hearst <derekallenhearst@gmail.com> Date: Wed, 23 Sep 2026 21:58:17 -0600 Subject: [PATCH 23/23] docs(chat): #35's delete notes describe the one delete path it shares with #18 docs/chat/spec.md and #35's section of the re-audit said a delete stops the turn and then deletes. Since the merge they run through #18's deleteConversationForUser, which checks ownership first and waits up to ten seconds for the stopped turn to save what it had done; both now say so and point at chat.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/chat/spec.md | 2 +- docs/structure/parity-reaudit-2026-09-22.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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/parity-reaudit-2026-09-22.md b/docs/structure/parity-reaudit-2026-09-22.md index 0d611d5a..d76a4eb2 100644 --- a/docs/structure/parity-reaudit-2026-09-22.md +++ b/docs/structure/parity-reaudit-2026-09-22.md @@ -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,