diff --git a/apps/desktop/drizzle/0100_segment_messages_fts_cjk.sql b/apps/desktop/drizzle/0100_segment_messages_fts_cjk.sql new file mode 100644 index 0000000000..e0ac49d1ec --- /dev/null +++ b/apps/desktop/drizzle/0100_segment_messages_fts_cjk.sql @@ -0,0 +1 @@ +SELECT 1; diff --git a/apps/desktop/drizzle/meta/0099_snapshot.json b/apps/desktop/drizzle/meta/0099_snapshot.json index 307ed3aafc..c2474c2c19 100644 --- a/apps/desktop/drizzle/meta/0099_snapshot.json +++ b/apps/desktop/drizzle/meta/0099_snapshot.json @@ -1,8 +1,8 @@ { + "id": "23a215f5-8a85-415d-a5e2-c17290af774d", + "prevId": "254c59fb-6027-454f-b038-de7548595750", "version": "6", "dialect": "sqlite", - "id": "96b877bd-5cae-4720-9b5d-d46d15e1f6cb", - "prevId": "254c59fb-6027-454f-b038-de7548595750", "tables": { "account_usage_snapshots": { "name": "account_usage_snapshots", @@ -65,15 +65,15 @@ "agent_input_queue_snapshots_session_id_sessions_id_fk": { "name": "agent_input_queue_snapshots_session_id_sessions_id_fk", "tableFrom": "agent_input_queue_snapshots", - "tableTo": "sessions", "columnsFrom": [ "session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -924,15 +924,15 @@ "im_bindings_target_session_id_sessions_id_fk": { "name": "im_bindings_target_session_id_sessions_id_fk", "tableFrom": "im_bindings", - "tableTo": "sessions", "columnsFrom": [ "target_session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": { @@ -1201,15 +1201,15 @@ "media_refs_hash_media_blobs_hash_fk": { "name": "media_refs_hash_media_blobs_hash_fk", "tableFrom": "media_refs", - "tableTo": "media_blobs", "columnsFrom": [ "hash" ], + "tableTo": "media_blobs", "columnsTo": [ "hash" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -1328,23 +1328,23 @@ "session_id", "created_at" ], - "isUnique": false, - "where": "\"messages\".\"role\" = 'error' AND \"messages\".\"rewind_at\" IS NULL" + "where": "\"messages\".\"role\" = 'error' AND \"messages\".\"rewind_at\" IS NULL", + "isUnique": false } }, "foreignKeys": { "messages_session_id_sessions_id_fk": { "name": "messages_session_id_sessions_id_fk", "tableFrom": "messages", - "tableTo": "sessions", "columnsFrom": [ "session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -1498,8 +1498,8 @@ "columns": [ "lead_session_id" ], - "isUnique": true, - "where": "\"orca_teams\".\"status\" = 'active'" + "where": "\"orca_teams\".\"status\" = 'active'", + "isUnique": true }, "idx_orca_teams_status": { "name": "idx_orca_teams_status", @@ -1513,15 +1513,15 @@ "orca_teams_lead_session_id_sessions_id_fk": { "name": "orca_teams_lead_session_id_sessions_id_fk", "tableFrom": "orca_teams", - "tableTo": "sessions", "columnsFrom": [ "lead_session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -1588,15 +1588,15 @@ "orca_worker_creation_reservations_team_id_orca_teams_id_fk": { "name": "orca_worker_creation_reservations_team_id_orca_teams_id_fk", "tableFrom": "orca_worker_creation_reservations", - "tableTo": "orca_teams", "columnsFrom": [ "team_id" ], + "tableTo": "orca_teams", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -1708,8 +1708,8 @@ "columns": [ "team_id" ], - "isUnique": true, - "where": "\"orca_workers\".\"focused\" = true" + "where": "\"orca_workers\".\"focused\" = true", + "isUnique": true }, "idx_orca_workers_team_id": { "name": "idx_orca_workers_team_id", @@ -1730,28 +1730,28 @@ "orca_workers_team_id_orca_teams_id_fk": { "name": "orca_workers_team_id_orca_teams_id_fk", "tableFrom": "orca_workers", - "tableTo": "orca_teams", "columnsFrom": [ "team_id" ], + "tableTo": "orca_teams", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "orca_workers_session_id_sessions_id_fk": { "name": "orca_workers_session_id_sessions_id_fk", "tableFrom": "orca_workers", - "tableTo": "sessions", "columnsFrom": [ "session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -1936,23 +1936,23 @@ "columns": [ "session_id" ], - "isUnique": true, - "where": "\"right_sidebar_tabs\".\"kind\" = 'subagents'" + "where": "\"right_sidebar_tabs\".\"kind\" = 'subagents'", + "isUnique": true } }, "foreignKeys": { "right_sidebar_tabs_session_id_sessions_id_fk": { "name": "right_sidebar_tabs_session_id_sessions_id_fk", "tableFrom": "right_sidebar_tabs", - "tableTo": "sessions", "columnsFrom": [ "session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -2109,24 +2109,24 @@ "columns": [ "schedule_id" ], - "isUnique": false, - "where": "\"schedule_runs\".\"status\" = 'running'" + "where": "\"schedule_runs\".\"status\" = 'running'", + "isUnique": false }, "idx_schedule_runs_running_heartbeat": { "name": "idx_schedule_runs_running_heartbeat", "columns": [ "heartbeat_at" ], - "isUnique": false, - "where": "\"schedule_runs\".\"status\" = 'running' AND \"schedule_runs\".\"heartbeat_at\" IS NOT NULL" + "where": "\"schedule_runs\".\"status\" = 'running' AND \"schedule_runs\".\"heartbeat_at\" IS NOT NULL", + "isUnique": false }, "idx_schedule_runs_running_legacy": { "name": "idx_schedule_runs_running_legacy", "columns": [ "fired_at" ], - "isUnique": false, - "where": "\"schedule_runs\".\"status\" = 'running' AND \"schedule_runs\".\"heartbeat_at\" IS NULL" + "where": "\"schedule_runs\".\"status\" = 'running' AND \"schedule_runs\".\"heartbeat_at\" IS NULL", + "isUnique": false }, "idx_schedule_runs_unread_terminal": { "name": "idx_schedule_runs_unread_terminal", @@ -2135,8 +2135,8 @@ "status", "fired_at" ], - "isUnique": false, - "where": "\"schedule_runs\".\"read_at\" IS NULL AND \"schedule_runs\".\"status\" IN ('success', 'failed', 'aborted', 'interrupted')" + "where": "\"schedule_runs\".\"read_at\" IS NULL AND \"schedule_runs\".\"status\" IN ('success', 'failed', 'aborted', 'interrupted')", + "isUnique": false }, "idx_schedule_runs_session_latest": { "name": "idx_schedule_runs_session_latest", @@ -2145,36 +2145,36 @@ "fired_at", "id" ], - "isUnique": false, - "where": "\"schedule_runs\".\"session_id\" IS NOT NULL" + "where": "\"schedule_runs\".\"session_id\" IS NOT NULL", + "isUnique": false } }, "foreignKeys": { "schedule_runs_schedule_id_schedules_id_fk": { "name": "schedule_runs_schedule_id_schedules_id_fk", "tableFrom": "schedule_runs", - "tableTo": "schedules", "columnsFrom": [ "schedule_id" ], + "tableTo": "schedules", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "schedule_runs_session_id_sessions_id_fk": { "name": "schedule_runs_session_id_sessions_id_fk", "tableFrom": "schedule_runs", - "tableTo": "sessions", "columnsFrom": [ "session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -2219,28 +2219,28 @@ "schedule_session_latest_runs_session_id_sessions_id_fk": { "name": "schedule_session_latest_runs_session_id_sessions_id_fk", "tableFrom": "schedule_session_latest_runs", - "tableTo": "sessions", "columnsFrom": [ "session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "schedule_session_latest_runs_run_id_schedule_runs_id_fk": { "name": "schedule_session_latest_runs_run_id_schedule_runs_id_fk", "tableFrom": "schedule_session_latest_runs", - "tableTo": "schedule_runs", "columnsFrom": [ "run_id" ], + "tableTo": "schedule_runs", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -2568,28 +2568,28 @@ "schedules_target_session_id_sessions_id_fk": { "name": "schedules_target_session_id_sessions_id_fk", "tableFrom": "schedules", - "tableTo": "sessions", "columnsFrom": [ "target_session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" }, "schedules_skip_log_session_id_sessions_id_fk": { "name": "schedules_skip_log_session_id_sessions_id_fk", "tableFrom": "schedules", - "tableTo": "sessions", "columnsFrom": [ "skip_log_session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -2715,15 +2715,15 @@ "session_goals_session_id_sessions_id_fk": { "name": "session_goals_session_id_sessions_id_fk", "tableFrom": "session_goals", - "tableTo": "sessions", "columnsFrom": [ "session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -2814,15 +2814,15 @@ "session_pr_refs_session_id_sessions_id_fk": { "name": "session_pr_refs_session_id_sessions_id_fk", "tableFrom": "session_pr_refs", - "tableTo": "sessions", "columnsFrom": [ "session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -3109,14 +3109,6 @@ "autoincrement": false, "default": "'[]'" }, - "writable_dirs": { - "name": "writable_dirs", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, "remote_host_id": { "name": "remote_host_id", "type": "text", @@ -3271,15 +3263,15 @@ "sessions_parent_session_id_sessions_id_fk": { "name": "sessions_parent_session_id_sessions_id_fk", "tableFrom": "sessions", - "tableTo": "sessions", "columnsFrom": [ "parent_session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -3465,15 +3457,15 @@ "skill_usage_exposures_raw_file_path_skill_usage_sources_raw_file_path_fk": { "name": "skill_usage_exposures_raw_file_path_skill_usage_sources_raw_file_path_fk", "tableFrom": "skill_usage_exposures", - "tableTo": "skill_usage_sources", "columnsFrom": [ "raw_file_path" ], + "tableTo": "skill_usage_sources", "columnsTo": [ "raw_file_path" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -3634,28 +3626,28 @@ "subagent_run_aliases_session_id_sessions_id_fk": { "name": "subagent_run_aliases_session_id_sessions_id_fk", "tableFrom": "subagent_run_aliases", - "tableTo": "sessions", "columnsFrom": [ "session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "subagent_run_aliases_run_id_subagent_runs_id_fk": { "name": "subagent_run_aliases_run_id_subagent_runs_id_fk", "tableFrom": "subagent_run_aliases", - "tableTo": "subagent_runs", "columnsFrom": [ "run_id" ], + "tableTo": "subagent_runs", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": { @@ -3901,15 +3893,15 @@ "subagent_runs_session_id_sessions_id_fk": { "name": "subagent_runs_session_id_sessions_id_fk", "tableFrom": "subagent_runs", - "tableTo": "sessions", "columnsFrom": [ "session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -4064,41 +4056,41 @@ "wechat_file_attachments_binding_epoch_wechat_sync_state_binding_epoch_fk": { "name": "wechat_file_attachments_binding_epoch_wechat_sync_state_binding_epoch_fk", "tableFrom": "wechat_file_attachments", - "tableTo": "wechat_sync_state", "columnsFrom": [ "binding_epoch" ], + "tableTo": "wechat_sync_state", "columnsTo": [ "binding_epoch" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "wechat_file_attachments_task_id_wechat_inbox_id_fk": { "name": "wechat_file_attachments_task_id_wechat_inbox_id_fk", "tableFrom": "wechat_file_attachments", - "tableTo": "wechat_inbox", "columnsFrom": [ "task_id" ], + "tableTo": "wechat_inbox", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "wechat_file_attachments_session_id_sessions_id_fk": { "name": "wechat_file_attachments_session_id_sessions_id_fk", "tableFrom": "wechat_file_attachments", - "tableTo": "sessions", "columnsFrom": [ "session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -4279,36 +4271,36 @@ "binding_epoch", "session_id" ], - "isUnique": true, - "where": "\"wechat_inbox\".\"session_id\" IS NOT NULL AND \"wechat_inbox\".\"status\" IN ('dispatching', 'accepted_running', 'waiting_desktop', 'delivery_pending')" + "where": "\"wechat_inbox\".\"session_id\" IS NOT NULL AND \"wechat_inbox\".\"status\" IN ('dispatching', 'accepted_running', 'waiting_desktop', 'delivery_pending')", + "isUnique": true } }, "foreignKeys": { "wechat_inbox_binding_epoch_wechat_sync_state_binding_epoch_fk": { "name": "wechat_inbox_binding_epoch_wechat_sync_state_binding_epoch_fk", "tableFrom": "wechat_inbox", - "tableTo": "wechat_sync_state", "columnsFrom": [ "binding_epoch" ], + "tableTo": "wechat_sync_state", "columnsTo": [ "binding_epoch" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "wechat_inbox_session_id_sessions_id_fk": { "name": "wechat_inbox_session_id_sessions_id_fk", "tableFrom": "wechat_inbox", - "tableTo": "sessions", "columnsFrom": [ "session_id" ], + "tableTo": "sessions", "columnsTo": [ "id" ], - "onDelete": "set null", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "set null" } }, "compositePrimaryKeys": {}, @@ -4445,28 +4437,28 @@ "wechat_outbox_binding_epoch_wechat_sync_state_binding_epoch_fk": { "name": "wechat_outbox_binding_epoch_wechat_sync_state_binding_epoch_fk", "tableFrom": "wechat_outbox", - "tableTo": "wechat_sync_state", "columnsFrom": [ "binding_epoch" ], + "tableTo": "wechat_sync_state", "columnsTo": [ "binding_epoch" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" }, "wechat_outbox_task_id_wechat_inbox_id_fk": { "name": "wechat_outbox_task_id_wechat_inbox_id_fk", "tableFrom": "wechat_outbox", - "tableTo": "wechat_inbox", "columnsFrom": [ "task_id" ], + "tableTo": "wechat_inbox", "columnsTo": [ "id" ], - "onDelete": "cascade", - "onUpdate": "no action" + "onUpdate": "no action", + "onDelete": "cascade" } }, "compositePrimaryKeys": {}, @@ -4527,8 +4519,8 @@ "columns": [ "is_active" ], - "isUnique": true, - "where": "\"wechat_sync_state\".\"is_active\" = 1" + "where": "\"wechat_sync_state\".\"is_active\" = 1", + "isUnique": true } }, "foreignKeys": {}, @@ -4540,9 +4532,9 @@ "views": {}, "enums": {}, "_meta": { + "columns": {}, "schemas": {}, - "tables": {}, - "columns": {} + "tables": {} }, "internal": { "indexes": { diff --git a/apps/desktop/drizzle/meta/0100_snapshot.json b/apps/desktop/drizzle/meta/0100_snapshot.json new file mode 100644 index 0000000000..af1a47d9fa --- /dev/null +++ b/apps/desktop/drizzle/meta/0100_snapshot.json @@ -0,0 +1,4557 @@ +{ + "id": "1c0c9a94-48aa-47c5-96f7-fba5d408570e", + "prevId": "23a215f5-8a85-415d-a5e2-c17290af774d", + "version": "6", + "dialect": "sqlite", + "tables": { + "account_usage_snapshots": { + "name": "account_usage_snapshots", + "columns": { + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "snapshot": { + "name": "snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_input_queue_snapshots": { + "name": "agent_input_queue_snapshots", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_input_queue_snapshots_session_id_sessions_id_fk": { + "name": "agent_input_queue_snapshots_session_id_sessions_id_fk", + "tableFrom": "agent_input_queue_snapshots", + "columnsFrom": [ + "session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "custom_mcp_servers": { + "name": "custom_mcp_servers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_custom_mcp_servers_sort_order": { + "name": "idx_custom_mcp_servers_sort_order", + "columns": [ + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "custom_providers": { + "name": "custom_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "runtimes": { + "name": "runtimes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_custom_providers_sort_order": { + "name": "idx_custom_providers_sort_order", + "columns": [ + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_model_usage": { + "name": "daily_model_usage", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_amount": { + "name": "cost_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_currency": { + "name": "cost_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "cost_is_approximate": { + "name": "cost_is_approximate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_create_tokens": { + "name": "cache_create_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_model_usage_day_agent_kind_model_cost_currency_pk": { + "columns": [ + "day", + "agent_kind", + "model", + "cost_currency" + ], + "name": "daily_model_usage_day_agent_kind_model_cost_currency_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_spend": { + "name": "daily_spend", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_amount": { + "name": "cost_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_currency": { + "name": "cost_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "cost_is_approximate": { + "name": "cost_is_approximate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_spend_day_cost_currency_pk": { + "columns": [ + "day", + "cost_currency" + ], + "name": "daily_spend_day_cost_currency_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_link_ownership": { + "name": "device_link_ownership", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_pid": { + "name": "owner_pid", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_label": { + "name": "owner_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "embedding_jobs": { + "name": "embedding_jobs", + "columns": { + "rowid": { + "name": "rowid", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vec_table": { + "name": "vec_table", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_embedding_jobs_natural": { + "name": "uniq_embedding_jobs_natural", + "columns": [ + "source", + "source_id", + "chunk_index", + "model_id" + ], + "isUnique": true + }, + "idx_embedding_jobs_status_scheduled": { + "name": "idx_embedding_jobs_status_scheduled", + "columns": [ + "status", + "scheduled_at" + ], + "isUnique": false + }, + "idx_embedding_jobs_source_id": { + "name": "idx_embedding_jobs_source_id", + "columns": [ + "source", + "source_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "embedding_meta": { + "name": "embedding_meta", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ghost_cards": { + "name": "ghost_cards", + "columns": { + "call_id": { + "name": "call_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ghost_id": { + "name": "ghost_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "html": { + "name": "html", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "v": { + "name": "v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ghost_cards_updated_at_idx": { + "name": "ghost_cards_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hook_group_context_cursors": { + "name": "hook_group_context_cursors", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cursor_key": { + "name": "cursor_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cursor_id": { + "name": "cursor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "hook_group_context_cursors_updated_at_idx": { + "name": "hook_group_context_cursors_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "hook_group_context_cursors_provider_cursor_key_pk": { + "columns": [ + "provider", + "cursor_key" + ], + "name": "hook_group_context_cursors_provider_cursor_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hook_group_message_stats": { + "name": "hook_group_message_stats", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_bytes": { + "name": "text_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hook_group_messages": { + "name": "hook_group_messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chat_name": { + "name": "chat_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_bot": { + "name": "is_bot", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_names": { + "name": "file_names", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "hook_group_messages_msg_idx": { + "name": "hook_group_messages_msg_idx", + "columns": [ + "provider", + "chat_id", + "thread_id", + "message_id" + ], + "isUnique": true + }, + "hook_group_messages_window_idx": { + "name": "hook_group_messages_window_idx", + "columns": [ + "provider", + "chat_id", + "thread_id", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "im_bindings": { + "name": "im_bindings", + "columns": { + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bot_context_id": { + "name": "bot_context_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "target_session_id": { + "name": "target_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attached_at": { + "name": "attached_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attached_via_card_message_id": { + "name": "attached_via_card_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_im_bindings_target": { + "name": "idx_im_bindings_target", + "columns": [ + "target_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "im_bindings_target_session_id_sessions_id_fk": { + "name": "im_bindings_target_session_id_sessions_id_fk", + "tableFrom": "im_bindings", + "columnsFrom": [ + "target_session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "im_bindings_channel_bot_context_id_user_id_scope_key_pk": { + "columns": [ + "channel", + "bot_context_id", + "user_id", + "scope_key" + ], + "name": "im_bindings_channel_bot_context_id_user_id_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media_blobs": { + "name": "media_blobs", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ext": { + "name": "ext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_cache": { + "name": "is_cache", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_access_at": { + "name": "last_access_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media_invocations": { + "name": "media_invocations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guide_revision": { + "name": "guide_revision", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guide_json": { + "name": "guide_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_json": { + "name": "response_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_invocations_owner_created_at_idx": { + "name": "media_invocations_owner_created_at_idx", + "columns": [ + "owner", + "created_at" + ], + "isUnique": false + }, + "media_invocations_owner_state_idx": { + "name": "media_invocations_owner_state_idx", + "columns": [ + "owner", + "state" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media_refs": { + "name": "media_refs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_kind": { + "name": "ref_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_session_id": { + "name": "origin_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_refs_hash_idx": { + "name": "media_refs_hash_idx", + "columns": [ + "hash" + ], + "isUnique": false + }, + "media_refs_ref_idx": { + "name": "media_refs_ref_idx", + "columns": [ + "ref_kind", + "ref_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "media_refs_hash_media_blobs_hash_fk": { + "name": "media_refs_hash_media_blobs_hash_fk", + "tableFrom": "media_refs", + "columnsFrom": [ + "hash" + ], + "tableTo": "media_blobs", + "columnsTo": [ + "hash" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_use_id": { + "name": "tool_use_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_meta": { + "name": "agent_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rewind_at": { + "name": "rewind_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_messages_session_client": { + "name": "uniq_messages_session_client", + "columns": [ + "session_id", + "client_id" + ], + "isUnique": true + }, + "idx_messages_session_created": { + "name": "idx_messages_session_created", + "columns": [ + "session_id", + "created_at" + ], + "isUnique": false + }, + "idx_messages_created_at": { + "name": "idx_messages_created_at", + "columns": [ + "created_at", + "id" + ], + "isUnique": false + }, + "idx_messages_rewind_at": { + "name": "idx_messages_rewind_at", + "columns": [ + "rewind_at" + ], + "isUnique": false + }, + "idx_messages_active_error_tail": { + "name": "idx_messages_active_error_tail", + "columns": [ + "session_id", + "created_at" + ], + "where": "\"messages\".\"role\" = 'error' AND \"messages\".\"rewind_at\" IS NULL", + "isUnique": false + } + }, + "foreignKeys": { + "messages_session_id_sessions_id_fk": { + "name": "messages_session_id_sessions_id_fk", + "tableFrom": "messages", + "columnsFrom": [ + "session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages_fts_rows": { + "name": "messages_fts_rows", + "columns": { + "fts_rowid": { + "name": "fts_rowid", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "messages_fts_rows_message_id_idx": { + "name": "messages_fts_rows_message_id_idx", + "columns": [ + "message_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "migration_history": { + "name": "migration_history", + "columns": { + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "migration_meta": { + "name": "migration_meta", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orca_teams": { + "name": "orca_teams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "lead_session_id": { + "name": "lead_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_active_team_per_lead": { + "name": "uniq_active_team_per_lead", + "columns": [ + "lead_session_id" + ], + "where": "\"orca_teams\".\"status\" = 'active'", + "isUnique": true + }, + "idx_orca_teams_status": { + "name": "idx_orca_teams_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "orca_teams_lead_session_id_sessions_id_fk": { + "name": "orca_teams_lead_session_id_sessions_id_fk", + "tableFrom": "orca_teams", + "columnsFrom": [ + "lead_session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orca_worker_creation_reservations": { + "name": "orca_worker_creation_reservations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_orca_worker_creation_reservations_team_label": { + "name": "uniq_orca_worker_creation_reservations_team_label", + "columns": [ + "team_id", + "lower(\"label\")" + ], + "isUnique": true + }, + "idx_orca_worker_creation_reservations_expires_at": { + "name": "idx_orca_worker_creation_reservations_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "orca_worker_creation_reservations_team_id_orca_teams_id_fk": { + "name": "orca_worker_creation_reservations_team_id_orca_teams_id_fk", + "tableFrom": "orca_worker_creation_reservations", + "columnsFrom": [ + "team_id" + ], + "tableTo": "orca_teams", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orca_workers": { + "name": "orca_workers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_branch": { + "name": "worktree_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'developer'" + }, + "focused": { + "name": "focused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "idle_since": { + "name": "idle_since", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_orca_workers_session_id": { + "name": "uniq_orca_workers_session_id", + "columns": [ + "session_id" + ], + "isUnique": true + }, + "uniq_orca_workers_team_label": { + "name": "uniq_orca_workers_team_label", + "columns": [ + "team_id", + "lower(\"label\")" + ], + "isUnique": true + }, + "uniq_orca_workers_focused_per_team": { + "name": "uniq_orca_workers_focused_per_team", + "columns": [ + "team_id" + ], + "where": "\"orca_workers\".\"focused\" = true", + "isUnique": true + }, + "idx_orca_workers_team_id": { + "name": "idx_orca_workers_team_id", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "idx_orca_workers_status": { + "name": "idx_orca_workers_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "orca_workers_team_id_orca_teams_id_fk": { + "name": "orca_workers_team_id_orca_teams_id_fk", + "tableFrom": "orca_workers", + "columnsFrom": [ + "team_id" + ], + "tableTo": "orca_teams", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "orca_workers_session_id_sessions_id_fk": { + "name": "orca_workers_session_id_sessions_id_fk", + "tableFrom": "orca_workers", + "columnsFrom": [ + "session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_aliases": { + "name": "project_aliases", + "columns": { + "project_key": { + "name": "project_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_project_aliases_updated_at": { + "name": "idx_project_aliases_updated_at", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_automation_consents": { + "name": "project_automation_consents", + "columns": { + "working_dir": { + "name": "working_dir", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "consented_at": { + "name": "consented_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_hash": { + "name": "config_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "recent_workdirs": { + "name": "recent_workdirs", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_recent_workdirs_last_used_at": { + "name": "idx_recent_workdirs_last_used_at", + "columns": [ + "last_used_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "right_sidebar_tabs": { + "name": "right_sidebar_tabs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "right_sidebar_tabs_session_idx": { + "name": "right_sidebar_tabs_session_idx", + "columns": [ + "session_id", + "position" + ], + "isUnique": false + }, + "right_sidebar_tabs_subagents_singleton_idx": { + "name": "right_sidebar_tabs_subagents_singleton_idx", + "columns": [ + "session_id" + ], + "where": "\"right_sidebar_tabs\".\"kind\" = 'subagents'", + "isUnique": true + } + }, + "foreignKeys": { + "right_sidebar_tabs_session_id_sessions_id_fk": { + "name": "right_sidebar_tabs_session_id_sessions_id_fk", + "tableFrom": "right_sidebar_tabs", + "columnsFrom": [ + "session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_runs": { + "name": "schedule_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fired_at": { + "name": "fired_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_msg": { + "name": "error_msg", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "estimated_value_usd": { + "name": "estimated_value_usd", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_amount": { + "name": "cost_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "estimated_value_amount": { + "name": "estimated_value_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_currency": { + "name": "cost_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_is_approximate": { + "name": "cost_is_approximate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cost_attribution": { + "name": "cost_attribution", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'legacy'" + }, + "result_text": { + "name": "result_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pre_run_hook_result": { + "name": "pre_run_hook_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_schedule_runs_schedule": { + "name": "idx_schedule_runs_schedule", + "columns": [ + "schedule_id", + "fired_at" + ], + "isUnique": false + }, + "idx_schedule_runs_running_schedule": { + "name": "idx_schedule_runs_running_schedule", + "columns": [ + "schedule_id" + ], + "where": "\"schedule_runs\".\"status\" = 'running'", + "isUnique": false + }, + "idx_schedule_runs_running_heartbeat": { + "name": "idx_schedule_runs_running_heartbeat", + "columns": [ + "heartbeat_at" + ], + "where": "\"schedule_runs\".\"status\" = 'running' AND \"schedule_runs\".\"heartbeat_at\" IS NOT NULL", + "isUnique": false + }, + "idx_schedule_runs_running_legacy": { + "name": "idx_schedule_runs_running_legacy", + "columns": [ + "fired_at" + ], + "where": "\"schedule_runs\".\"status\" = 'running' AND \"schedule_runs\".\"heartbeat_at\" IS NULL", + "isUnique": false + }, + "idx_schedule_runs_unread_terminal": { + "name": "idx_schedule_runs_unread_terminal", + "columns": [ + "schedule_id", + "status", + "fired_at" + ], + "where": "\"schedule_runs\".\"read_at\" IS NULL AND \"schedule_runs\".\"status\" IN ('success', 'failed', 'aborted', 'interrupted')", + "isUnique": false + }, + "idx_schedule_runs_session_latest": { + "name": "idx_schedule_runs_session_latest", + "columns": [ + "session_id", + "fired_at", + "id" + ], + "where": "\"schedule_runs\".\"session_id\" IS NOT NULL", + "isUnique": false + } + }, + "foreignKeys": { + "schedule_runs_schedule_id_schedules_id_fk": { + "name": "schedule_runs_schedule_id_schedules_id_fk", + "tableFrom": "schedule_runs", + "columnsFrom": [ + "schedule_id" + ], + "tableTo": "schedules", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "schedule_runs_session_id_sessions_id_fk": { + "name": "schedule_runs_session_id_sessions_id_fk", + "tableFrom": "schedule_runs", + "columnsFrom": [ + "session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_session_latest_runs": { + "name": "schedule_session_latest_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fired_at": { + "name": "fired_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_schedule_session_latest_runs_run": { + "name": "idx_schedule_session_latest_runs_run", + "columns": [ + "run_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "schedule_session_latest_runs_session_id_sessions_id_fk": { + "name": "schedule_session_latest_runs_session_id_sessions_id_fk", + "tableFrom": "schedule_session_latest_runs", + "columnsFrom": [ + "session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "schedule_session_latest_runs_run_id_schedule_runs_id_fk": { + "name": "schedule_session_latest_runs_run_id_schedule_runs_id_fk", + "tableFrom": "schedule_session_latest_runs", + "columnsFrom": [ + "run_id" + ], + "tableTo": "schedule_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedules": { + "name": "schedules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'prompt'" + }, + "job_config": { + "name": "job_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_mode": { + "name": "execution_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "script_config": { + "name": "script_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + }, + "project_config_id": { + "name": "project_config_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_session_fallback": { + "name": "legacy_session_fallback", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cron'" + }, + "cron_expr": { + "name": "cron_expr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recurring": { + "name": "recurring", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "manual": { + "name": "manual", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "interval_ms": { + "name": "interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "effort": { + "name": "effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fast_mode": { + "name": "fast_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "working_dir": { + "name": "working_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_kind": { + "name": "workspace_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "use_worktree": { + "name": "use_worktree", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "target_session_id": { + "name": "target_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "persistent_session": { + "name": "persistent_session", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "silent_when_idle": { + "name": "silent_when_idle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pre_run_hook_command": { + "name": "pre_run_hook_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pre_run_hook_timeout_ms": { + "name": "pre_run_hook_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skip_log_session_id": { + "name": "skip_log_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notify_desktop": { + "name": "notify_desktop", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "notify_feishu": { + "name": "notify_feishu", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notify_wecom_group": { + "name": "notify_wecom_group", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_finished_at": { + "name": "last_finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_fire_at": { + "name": "next_fire_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expire_at": { + "name": "expire_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_schedules_active_next": { + "name": "idx_schedules_active_next", + "columns": [ + "status", + "next_fire_at" + ], + "isUnique": false + }, + "idx_schedules_target_session": { + "name": "idx_schedules_target_session", + "columns": [ + "target_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "schedules_target_session_id_sessions_id_fk": { + "name": "schedules_target_session_id_sessions_id_fk", + "tableFrom": "schedules", + "columnsFrom": [ + "target_session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "schedules_skip_log_session_id_sessions_id_fk": { + "name": "schedules_skip_log_session_id_sessions_id_fk", + "tableFrom": "schedules", + "columnsFrom": [ + "skip_log_session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_goals": { + "name": "session_goals", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "objective": { + "name": "objective", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "budget_tokens": { + "name": "budget_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_turns": { + "name": "max_turns", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "no_progress_limit": { + "name": "no_progress_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "turns_used": { + "name": "turns_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "no_progress_streak": { + "name": "no_progress_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "usage_reset_at": { + "name": "usage_reset_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_reason": { + "name": "last_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_session_goals_status": { + "name": "idx_session_goals_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_goals_session_id_sessions_id_fk": { + "name": "session_goals_session_id_sessions_id_fk", + "tableFrom": "session_goals", + "columnsFrom": [ + "session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_pr_refs": { + "name": "session_pr_refs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_session_pr_refs": { + "name": "uniq_session_pr_refs", + "columns": [ + "session_id", + "owner", + "repo", + "pr_number" + ], + "isUnique": true + }, + "idx_session_pr_refs_session_last_seen": { + "name": "idx_session_pr_refs_session_last_seen", + "columns": [ + "session_id", + "last_seen_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_pr_refs_session_id_sessions_id_fk": { + "name": "session_pr_refs_session_id_sessions_id_fk", + "tableFrom": "session_pr_refs", + "columnsFrom": [ + "session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'New Maker'" + }, + "working_dir": { + "name": "working_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_kind": { + "name": "workspace_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude-sonnet-4-6'" + }, + "effort": { + "name": "effort", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'high'" + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ask'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "sdk_session_id": { + "name": "sdk_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_token_usage": { + "name": "total_token_usage", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_cost_usd": { + "name": "total_cost_usd", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_cost_amount": { + "name": "total_cost_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_cost_currency": { + "name": "total_cost_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_cost_is_approximate": { + "name": "total_cost_is_approximate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "context_tokens": { + "name": "context_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "fast_mode": { + "name": "fast_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "plan_mode_enabled": { + "name": "plan_mode_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cleared_at": { + "name": "cleared_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_send_at": { + "name": "user_send_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cc'" + }, + "orca_role": { + "name": "orca_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forked_at_message_id": { + "name": "forked_at_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'desktop'" + }, + "feishu_open_id": { + "name": "feishu_open_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feishu_bot_app_id": { + "name": "feishu_bot_app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "im_bot_context_id": { + "name": "im_bot_context_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "im_user_id": { + "name": "im_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_project_context": { + "name": "used_project_context", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "codex_history_has_product_prompt": { + "name": "codex_history_has_product_prompt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "codex_plan_json": { + "name": "codex_plan_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_dirs": { + "name": "extra_dirs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "remote_host_id": { + "name": "remote_host_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_turn_started_at": { + "name": "active_turn_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_turn_pid": { + "name": "active_turn_pid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_turn_ended_at": { + "name": "last_turn_ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "list_preview": { + "name": "list_preview", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "list_preview_role": { + "name": "list_preview_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "list_message_count": { + "name": "list_message_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_sessions_updated_at": { + "name": "idx_sessions_updated_at", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "idx_sessions_user_send_at": { + "name": "idx_sessions_user_send_at", + "columns": [ + "user_send_at" + ], + "isUnique": false + }, + "idx_sessions_sdk_session_id": { + "name": "idx_sessions_sdk_session_id", + "columns": [ + "sdk_session_id" + ], + "isUnique": false + }, + "idx_sessions_workdir_created": { + "name": "idx_sessions_workdir_created", + "columns": [ + "working_dir", + "created_at", + "id" + ], + "isUnique": false + }, + "idx_sessions_created_at": { + "name": "idx_sessions_created_at", + "columns": [ + "created_at", + "id" + ], + "isUnique": false + }, + "idx_sessions_workspace_kind": { + "name": "idx_sessions_workspace_kind", + "columns": [ + "workspace_kind" + ], + "isUnique": false + }, + "idx_sessions_parent_session_id": { + "name": "idx_sessions_parent_session_id", + "columns": [ + "parent_session_id" + ], + "isUnique": false + }, + "idx_sessions_orca_role": { + "name": "idx_sessions_orca_role", + "columns": [ + "orca_role" + ], + "isUnique": false + }, + "idx_sessions_worktree_path": { + "name": "idx_sessions_worktree_path", + "columns": [ + "worktree_path" + ], + "isUnique": false + }, + "idx_sessions_feishu_lookup": { + "name": "idx_sessions_feishu_lookup", + "columns": [ + "source", + "feishu_bot_app_id", + "feishu_open_id" + ], + "isUnique": false + }, + "idx_sessions_im_lookup": { + "name": "idx_sessions_im_lookup", + "columns": [ + "source", + "im_bot_context_id", + "im_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_parent_session_id_sessions_id_fk": { + "name": "sessions_parent_session_id_sessions_id_fk", + "tableFrom": "sessions", + "columnsFrom": [ + "parent_session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skill_usage_exposures": { + "name": "skill_usage_exposures", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "analyzer_version": { + "name": "analyzer_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'6'" + }, + "raw_file_path": { + "name": "raw_file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw_line_no": { + "name": "raw_line_no", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sdk_session_id": { + "name": "sdk_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_name": { + "name": "skill_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_path": { + "name": "skill_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skill_document_hash": { + "name": "skill_document_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exposure_content_hash": { + "name": "exposure_content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_hash_source": { + "name": "document_hash_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_use_id": { + "name": "tool_use_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seen_at": { + "name": "seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_call_count": { + "name": "tool_call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "repeated_tool_call_count": { + "name": "repeated_tool_call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "tool_error_count": { + "name": "tool_error_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "command_call_count": { + "name": "command_call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "command_failure_count": { + "name": "command_failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_skill_usage_exposures_skill_document_version": { + "name": "idx_skill_usage_exposures_skill_document_version", + "columns": [ + "analyzer_version", + "skill_name", + "skill_document_hash" + ], + "isUnique": false + }, + "idx_skill_usage_exposures_session": { + "name": "idx_skill_usage_exposures_session", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "idx_skill_usage_exposures_raw_file": { + "name": "idx_skill_usage_exposures_raw_file", + "columns": [ + "raw_file_path" + ], + "isUnique": false + } + }, + "foreignKeys": { + "skill_usage_exposures_raw_file_path_skill_usage_sources_raw_file_path_fk": { + "name": "skill_usage_exposures_raw_file_path_skill_usage_sources_raw_file_path_fk", + "tableFrom": "skill_usage_exposures", + "columnsFrom": [ + "raw_file_path" + ], + "tableTo": "skill_usage_sources", + "columnsTo": [ + "raw_file_path" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skill_usage_sources": { + "name": "skill_usage_sources", + "columns": { + "raw_file_path": { + "name": "raw_file_path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "analyzer_version": { + "name": "analyzer_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'6'" + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sdk_session_id": { + "name": "sdk_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mtime_ms": { + "name": "mtime_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_scanned_at": { + "name": "last_scanned_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ok'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_skill_usage_sources_session": { + "name": "idx_skill_usage_sources_session", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "idx_skill_usage_sources_sdk_session": { + "name": "idx_skill_usage_sources_sdk_session", + "columns": [ + "sdk_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subagent_run_aliases": { + "name": "subagent_run_aliases", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "subagent_run_aliases_lookup_idx": { + "name": "subagent_run_aliases_lookup_idx", + "columns": [ + "session_id", + "provider", + "alias", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "subagent_run_aliases_session_id_sessions_id_fk": { + "name": "subagent_run_aliases_session_id_sessions_id_fk", + "tableFrom": "subagent_run_aliases", + "columnsFrom": [ + "session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "subagent_run_aliases_run_id_subagent_runs_id_fk": { + "name": "subagent_run_aliases_run_id_subagent_runs_id_fk", + "tableFrom": "subagent_run_aliases", + "columnsFrom": [ + "run_id" + ], + "tableTo": "subagent_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "subagent_run_aliases_run_id_alias_pk": { + "columns": [ + "run_id", + "alias" + ], + "name": "subagent_run_aliases_run_id_alias_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subagent_runs": { + "name": "subagent_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logical_agent_id": { + "name": "logical_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_tool_use_id": { + "name": "parent_tool_use_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "provider_run_ids": { + "name": "provider_run_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "returned_result": { + "name": "returned_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "returned_result_empty": { + "name": "returned_result_empty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "returned_result_truncated": { + "name": "returned_result_truncated", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_uses": { + "name": "tool_uses", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "activity": { + "name": "activity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rewind_at": { + "name": "rewind_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "subagent_runs_logical_idx": { + "name": "subagent_runs_logical_idx", + "columns": [ + "session_id", + "provider", + "logical_agent_id" + ], + "isUnique": false + }, + "subagent_runs_session_idx": { + "name": "subagent_runs_session_idx", + "columns": [ + "session_id", + "rewind_at", + "deleted_at", + "started_at" + ], + "isUnique": false + }, + "subagent_runs_parent_tool_use_idx": { + "name": "subagent_runs_parent_tool_use_idx", + "columns": [ + "session_id", + "parent_tool_use_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "subagent_runs_session_id_sessions_id_fk": { + "name": "subagent_runs_session_id_sessions_id_fk", + "tableFrom": "subagent_runs", + "columnsFrom": [ + "session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vec_table_meta": { + "name": "vec_table_meta", + "columns": { + "vec_table": { + "name": "vec_table", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dim": { + "name": "dim", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registered_at": { + "name": "registered_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wechat_file_attachments": { + "name": "wechat_file_attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "binding_epoch": { + "name": "binding_epoch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abs_path": { + "name": "abs_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'staged'" + }, + "promoted_at": { + "name": "promoted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_wechat_file_attachments_task": { + "name": "idx_wechat_file_attachments_task", + "columns": [ + "binding_epoch", + "task_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "wechat_file_attachments_binding_epoch_wechat_sync_state_binding_epoch_fk": { + "name": "wechat_file_attachments_binding_epoch_wechat_sync_state_binding_epoch_fk", + "tableFrom": "wechat_file_attachments", + "columnsFrom": [ + "binding_epoch" + ], + "tableTo": "wechat_sync_state", + "columnsTo": [ + "binding_epoch" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "wechat_file_attachments_task_id_wechat_inbox_id_fk": { + "name": "wechat_file_attachments_task_id_wechat_inbox_id_fk", + "tableFrom": "wechat_file_attachments", + "columnsFrom": [ + "task_id" + ], + "tableTo": "wechat_inbox", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "wechat_file_attachments_session_id_sessions_id_fk": { + "name": "wechat_file_attachments_session_id_sessions_id_fk", + "tableFrom": "wechat_file_attachments", + "columnsFrom": [ + "session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wechat_inbox": { + "name": "wechat_inbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "binding_epoch": { + "name": "binding_epoch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform_message_id": { + "name": "platform_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform_seq": { + "name": "platform_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "peer_id": { + "name": "peer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform_created_at": { + "name": "platform_created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "conversation_epoch": { + "name": "conversation_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_nonce": { + "name": "context_nonce", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_ciphertext": { + "name": "context_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_tag": { + "name": "context_tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_wechat_inbox_platform_message": { + "name": "uniq_wechat_inbox_platform_message", + "columns": [ + "binding_epoch", + "platform_message_id" + ], + "isUnique": true + }, + "idx_wechat_inbox_queue": { + "name": "idx_wechat_inbox_queue", + "columns": [ + "binding_epoch", + "status", + "received_at" + ], + "isUnique": false + }, + "idx_wechat_inbox_lease": { + "name": "idx_wechat_inbox_lease", + "columns": [ + "binding_epoch", + "lease_until" + ], + "isUnique": false + }, + "idx_wechat_inbox_conversation": { + "name": "idx_wechat_inbox_conversation", + "columns": [ + "binding_epoch", + "peer_id", + "conversation_epoch" + ], + "isUnique": false + }, + "uniq_wechat_inbox_running_session": { + "name": "uniq_wechat_inbox_running_session", + "columns": [ + "binding_epoch", + "session_id" + ], + "where": "\"wechat_inbox\".\"session_id\" IS NOT NULL AND \"wechat_inbox\".\"status\" IN ('dispatching', 'accepted_running', 'waiting_desktop', 'delivery_pending')", + "isUnique": true + } + }, + "foreignKeys": { + "wechat_inbox_binding_epoch_wechat_sync_state_binding_epoch_fk": { + "name": "wechat_inbox_binding_epoch_wechat_sync_state_binding_epoch_fk", + "tableFrom": "wechat_inbox", + "columnsFrom": [ + "binding_epoch" + ], + "tableTo": "wechat_sync_state", + "columnsTo": [ + "binding_epoch" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "wechat_inbox_session_id_sessions_id_fk": { + "name": "wechat_inbox_session_id_sessions_id_fk", + "tableFrom": "wechat_inbox", + "columnsFrom": [ + "session_id" + ], + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wechat_outbox": { + "name": "wechat_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "binding_epoch": { + "name": "binding_epoch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_json": { + "name": "media_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_wechat_outbox_client_id": { + "name": "uniq_wechat_outbox_client_id", + "columns": [ + "binding_epoch", + "client_id" + ], + "isUnique": true + }, + "idx_wechat_outbox_delivery": { + "name": "idx_wechat_outbox_delivery", + "columns": [ + "binding_epoch", + "status", + "next_retry_at" + ], + "isUnique": false + }, + "idx_wechat_outbox_task": { + "name": "idx_wechat_outbox_task", + "columns": [ + "binding_epoch", + "task_id", + "chunk_index" + ], + "isUnique": false + } + }, + "foreignKeys": { + "wechat_outbox_binding_epoch_wechat_sync_state_binding_epoch_fk": { + "name": "wechat_outbox_binding_epoch_wechat_sync_state_binding_epoch_fk", + "tableFrom": "wechat_outbox", + "columnsFrom": [ + "binding_epoch" + ], + "tableTo": "wechat_sync_state", + "columnsTo": [ + "binding_epoch" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "wechat_outbox_task_id_wechat_inbox_id_fk": { + "name": "wechat_outbox_task_id_wechat_inbox_id_fk", + "tableFrom": "wechat_outbox", + "columnsFrom": [ + "task_id" + ], + "tableTo": "wechat_inbox", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wechat_sync_state": { + "name": "wechat_sync_state", + "columns": { + "binding_epoch": { + "name": "binding_epoch", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sync_cursor": { + "name": "sync_cursor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "last_poll_at": { + "name": "last_poll_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_wechat_sync_active": { + "name": "uniq_wechat_sync_active", + "columns": [ + "is_active" + ], + "where": "\"wechat_sync_state\".\"is_active\" = 1", + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": { + "uniq_orca_worker_creation_reservations_team_label": { + "columns": { + "lower(\"label\")": { + "isExpression": true + } + } + }, + "uniq_orca_workers_team_label": { + "columns": { + "lower(\"label\")": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/apps/desktop/drizzle/meta/_journal.json b/apps/desktop/drizzle/meta/_journal.json index 4d4df605ea..8818669671 100644 --- a/apps/desktop/drizzle/meta/_journal.json +++ b/apps/desktop/drizzle/meta/_journal.json @@ -701,6 +701,13 @@ "when": 1787938177701, "tag": "0099_boring_champions", "breakpoints": true + }, + { + "idx": 100, + "version": "6", + "when": 1787992937440, + "tag": "0100_segment_messages_fts_cjk", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/desktop/drizzle/scripts/0100_segment_messages_fts_cjk.ts b/apps/desktop/drizzle/scripts/0100_segment_messages_fts_cjk.ts new file mode 100644 index 0000000000..7577d220d3 --- /dev/null +++ b/apps/desktop/drizzle/scripts/0100_segment_messages_fts_cjk.ts @@ -0,0 +1,280 @@ +import type Database from 'better-sqlite3'; + +const FTS_ROLE_WHITELIST = "('user', 'assistant', 'ask_user', 'plan_review')"; +const HAN_CHAR_RE = /\p{Script=Han}/u; +const LETTER_OR_NUMBER_RE = /[\p{L}\p{N}]/u; +const MARK_RE = /\p{M}/u; + +function isHanCodePoint(ch: string): boolean { + return HAN_CHAR_RE.test(ch); +} + +function isLetterOrNumber(ch: string): boolean { + return LETTER_OR_NUMBER_RE.test(ch); +} + +function isMark(ch: string): boolean { + return MARK_RE.test(ch); +} + +function cjkSeg(input: unknown): string | null { + if (input == null) return null; + const text = typeof input === 'string' ? input : String(input); + if (text.length === 0) return text; + const chars = Array.from(text); + let out = ''; + let lastBoundary = ''; + for (let i = 0; i < chars.length; i += 1) { + const ch = chars[i]; + if (isMark(ch)) { + out += ch; + continue; + } + if (lastBoundary) { + const hanNow = isHanCodePoint(ch); + const hanPrev = isHanCodePoint(lastBoundary); + if ( + (hanNow && hanPrev) || + (hanNow && isLetterOrNumber(lastBoundary)) || + (hanPrev && isLetterOrNumber(ch)) + ) { + out += ' '; + } + } + out += ch; + lastBoundary = ch; + } + return out; +} + +function registerCjkSeg(db: Database.Database): void { + db.function('cjk_seg', { deterministic: true }, cjkSeg); +} + +function tableExists(db: Database.Database, name: string): boolean { + return !!db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(name); +} + +function isConstraintError(error: unknown): boolean { + return ( + error instanceof Error && + typeof (error as Error & { code?: unknown }).code === 'string' && + String((error as Error & { code: string }).code).startsWith('SQLITE_CONSTRAINT') + ); +} + +function rebuildFts(db: Database.Database): void { + db.exec('DROP TABLE IF EXISTS messages_fts;'); + db.exec('DELETE FROM messages_fts_rows;'); + db.exec(` + CREATE VIRTUAL TABLE messages_fts USING fts5( + message_id UNINDEXED, + session_id UNINDEXED, + role UNINDEXED, + content, + tokenize='porter unicode61' + ); + `); + db.exec(` + INSERT INTO messages_fts_rows(message_id) + SELECT id + FROM messages + WHERE rewind_at IS NULL + AND role IN ${FTS_ROLE_WHITELIST} + ORDER BY rowid; + `); + db.exec(` + INSERT INTO messages_fts(rowid, message_id, session_id, role, content) + SELECT r.fts_rowid, m.id, m.session_id, m.role, cjk_seg(m.content) + FROM messages m + JOIN messages_fts_rows r ON r.message_id = m.id; + `); +} + +function reuseExistingFtsRows(db: Database.Database): boolean { + db.exec('DELETE FROM messages_fts_rows;'); + try { + db.exec(` + INSERT INTO messages_fts_rows(fts_rowid, message_id) + SELECT rowid, message_id + FROM messages_fts; + `); + } catch (error) { + if (isConstraintError(error)) return false; + throw error; + } + + const invalidFtsRow = db + .prepare( + `SELECT 1 + FROM messages_fts_rows r + LEFT JOIN messages m ON m.id = r.message_id + WHERE m.id IS NULL + OR m.rewind_at IS NOT NULL + OR m.role NOT IN ${FTS_ROLE_WHITELIST} + LIMIT 1`, + ) + .get(); + if (invalidFtsRow) return false; + + const mismatchedFtsRow = db + .prepare( + `SELECT 1 + FROM messages_fts f + JOIN messages_fts_rows r ON r.fts_rowid = f.rowid + JOIN messages m ON m.id = r.message_id + WHERE f.message_id IS NOT m.id + OR f.session_id IS NOT m.session_id + OR f.role IS NOT m.role + OR f.content IS NOT cjk_seg(m.content) + LIMIT 1`, + ) + .get(); + if (mismatchedFtsRow) return false; + + const missingFtsRow = db + .prepare( + `SELECT 1 + FROM messages m + LEFT JOIN messages_fts_rows r ON r.message_id = m.id + WHERE m.rewind_at IS NULL + AND m.role IN ${FTS_ROLE_WHITELIST} + AND r.message_id IS NULL + LIMIT 1`, + ) + .get(); + return !missingFtsRow; +} + +function createTriggers(db: Database.Database): void { + db.exec(` + CREATE TRIGGER messages_fts_insert + AFTER INSERT ON messages + WHEN new.rewind_at IS NULL AND new.role IN ${FTS_ROLE_WHITELIST} + AND NOT EXISTS (SELECT 1 FROM pragma_function_list WHERE name='cjk_seg') + BEGIN + INSERT OR IGNORE INTO messages_fts_rows(message_id) VALUES (new.id); + INSERT OR REPLACE INTO messages_fts(rowid, message_id, session_id, role, content) + SELECT fts_rowid, new.id, new.session_id, new.role, new.content + FROM messages_fts_rows + WHERE message_id = new.id; + END; + `); + + db.exec(` + CREATE TRIGGER messages_fts_delete + AFTER DELETE ON messages + BEGIN + DELETE FROM messages_fts + WHERE rowid = ( + SELECT fts_rowid FROM messages_fts_rows WHERE message_id = old.id + ); + DELETE FROM messages_fts_rows WHERE message_id = old.id; + END; + `); + + db.exec(` + CREATE TRIGGER messages_fts_update + AFTER UPDATE OF id, session_id, role, content, rewind_at ON messages + WHEN (old.id IS NOT new.id + OR old.session_id IS NOT new.session_id + OR old.role IS NOT new.role + OR old.content IS NOT new.content + OR old.rewind_at IS NOT new.rewind_at) + AND NOT EXISTS (SELECT 1 FROM pragma_function_list WHERE name='cjk_seg') + BEGIN + DELETE FROM messages_fts + WHERE rowid = ( + SELECT fts_rowid FROM messages_fts_rows WHERE message_id = old.id + ); + UPDATE messages_fts_rows + SET message_id = new.id + WHERE message_id = old.id AND old.id IS NOT new.id; + INSERT OR IGNORE INTO messages_fts_rows(message_id) + SELECT new.id + WHERE new.rewind_at IS NULL AND new.role IN ${FTS_ROLE_WHITELIST}; + INSERT OR REPLACE INTO messages_fts(rowid, message_id, session_id, role, content) + SELECT fts_rowid, new.id, new.session_id, new.role, new.content + FROM messages_fts_rows + WHERE message_id = new.id + AND new.rewind_at IS NULL + AND new.role IN ${FTS_ROLE_WHITELIST}; + END; + `); +} + +function installCjkSegTempTriggers(db: Database.Database): void { + if (!tableExists(db, 'messages') || !tableExists(db, 'messages_fts') || !tableExists(db, 'messages_fts_rows')) { + return; + } + + db.exec('DROP TRIGGER IF EXISTS temp.messages_fts_insert_cjk;'); + db.exec('DROP TRIGGER IF EXISTS temp.messages_fts_update_cjk;'); + + db.exec(` + CREATE TEMP TRIGGER messages_fts_insert_cjk + AFTER INSERT ON messages + WHEN new.rewind_at IS NULL AND new.role IN ${FTS_ROLE_WHITELIST} + BEGIN + INSERT OR IGNORE INTO messages_fts_rows(message_id) VALUES (new.id); + INSERT OR REPLACE INTO messages_fts(rowid, message_id, session_id, role, content) + SELECT fts_rowid, new.id, new.session_id, new.role, cjk_seg(new.content) + FROM messages_fts_rows + WHERE message_id = new.id; + END; + `); + + db.exec(` + CREATE TEMP TRIGGER messages_fts_update_cjk + AFTER UPDATE OF id, session_id, role, content, rewind_at ON messages + WHEN old.id IS NOT new.id + OR old.session_id IS NOT new.session_id + OR old.role IS NOT new.role + OR old.content IS NOT new.content + OR old.rewind_at IS NOT new.rewind_at + BEGIN + DELETE FROM messages_fts + WHERE rowid = ( + SELECT fts_rowid FROM messages_fts_rows WHERE message_id = old.id + ); + UPDATE messages_fts_rows + SET message_id = new.id + WHERE message_id = old.id AND old.id IS NOT new.id; + INSERT OR IGNORE INTO messages_fts_rows(message_id) + SELECT new.id + WHERE new.rewind_at IS NULL AND new.role IN ${FTS_ROLE_WHITELIST}; + INSERT OR REPLACE INTO messages_fts(rowid, message_id, session_id, role, content) + SELECT fts_rowid, new.id, new.session_id, new.role, cjk_seg(new.content) + FROM messages_fts_rows + WHERE message_id = new.id + AND new.rewind_at IS NULL + AND new.role IN ${FTS_ROLE_WHITELIST}; + END; + `); +} + +function createRowsTable(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS messages_fts_rows ( + fts_rowid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + message_id TEXT NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS messages_fts_rows_message_id_idx + ON messages_fts_rows(message_id); + `); +} + +function run(db: Database.Database): void { + registerCjkSeg(db); + db.exec('DROP TRIGGER IF EXISTS main.messages_fts_insert;'); + db.exec('DROP TRIGGER IF EXISTS main.messages_fts_delete;'); + db.exec('DROP TRIGGER IF EXISTS main.messages_fts_update;'); + + createRowsTable(db); + if (!tableExists(db, 'messages')) return; + if (!tableExists(db, 'messages_fts') || !reuseExistingFtsRows(db)) rebuildFts(db); + createTriggers(db); + installCjkSegTempTriggers(db); +} + +module.exports = { run }; diff --git a/apps/desktop/src/main/__tests__/betterSqliteFactory.test.ts b/apps/desktop/src/main/__tests__/betterSqliteFactory.test.ts index a5c63da6e9..0f7d33052d 100644 --- a/apps/desktop/src/main/__tests__/betterSqliteFactory.test.ts +++ b/apps/desktop/src/main/__tests__/betterSqliteFactory.test.ts @@ -10,6 +10,7 @@ import { resolveBetterSqliteNativeBinding, restrictDbFilePermissions, } from '../localDb/betterSqliteFactory'; +import { CJK_SEG_SQL_FN } from '../localDb/cjkSeg'; import { backupDb, restrictLegacyBackupPermissions } from '../localDb/backup'; // chmod / POSIX mode 位在 Windows(NTFS)上是 near-noop,权限模型不同,断言无意义; @@ -197,3 +198,15 @@ describe('createBetterSqliteDatabase 文件权限', () => { } }); }); + +describe('createBetterSqliteDatabase 注册 cjk_seg', () => { + it('打开连接后即可调用 cjk_seg', () => { + const db = createBetterSqliteDatabase(':memory:'); + try { + const row = db.prepare(`SELECT ${CJK_SEG_SQL_FN}(?) AS v`).get('边界') as { v: string }; + expect(row.v).toBe('边 界'); + } finally { + db.close(); + } + }); +}); diff --git a/apps/desktop/src/main/__tests__/schemaDriftRepair.test.ts b/apps/desktop/src/main/__tests__/schemaDriftRepair.test.ts index 9c2aa5f6c8..a02d95a8fd 100644 --- a/apps/desktop/src/main/__tests__/schemaDriftRepair.test.ts +++ b/apps/desktop/src/main/__tests__/schemaDriftRepair.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import Database from 'better-sqlite3'; import { getTableName, isTable } from 'drizzle-orm'; +import { createBetterSqliteDatabase } from '../localDb/betterSqliteFactory'; import * as localSchema from '../localDb/schema'; vi.mock('../logger', () => ({ @@ -62,7 +63,7 @@ describe('repairSchemaDrift', () => { }); it('repairs missing partial indexes with their WHERE clauses', () => { - const db = new Database(':memory:'); + const db = createBetterSqliteDatabase(':memory:'); try { const report = repairSchemaDrift(db); @@ -112,7 +113,7 @@ describe('repairSchemaDrift', () => { }); it('does not back up when metadata drift has no physical schema repair actions', async () => { - const db = new Database(':memory:'); + const db = createBetterSqliteDatabase(':memory:'); try { // 先构造完整物理 schema;上层即使仍检测到 migration-history drift,也不应备份。 expect(repairSchemaDrift(db).residual).toEqual([]); @@ -129,7 +130,7 @@ describe('repairSchemaDrift', () => { }); it('backs up before a real schema repair and skips backup on the second pass', async () => { - const db = new Database(':memory:'); + const db = createBetterSqliteDatabase(':memory:'); const events: string[] = []; try { const backup = vi.fn(async () => { @@ -167,7 +168,7 @@ describe('repairSchemaDrift', () => { }); it('does not mutate schema when the required backup fails', async () => { - const db = new Database(':memory:'); + const db = createBetterSqliteDatabase(':memory:'); try { const result = await repairSchemaDriftWithBackup(db, { backup: async () => null, @@ -196,7 +197,7 @@ describe('repairSchemaDrift', () => { }); it('returns an empty report (never residual) when db connection is already closed', () => { - const db = new Database(':memory:'); + const db = createBetterSqliteDatabase(':memory:'); db.close(); expect(db.open).toBe(false); @@ -207,7 +208,7 @@ describe('repairSchemaDrift', () => { }); it('records residual when a missing partial index cannot be recreated', () => { - const db = new Database(':memory:'); + const db = createBetterSqliteDatabase(':memory:'); try { // 两条 active 记录共享同一个 lead_session_id,会让 partial unique index 创建失败。 db.exec(` @@ -246,7 +247,7 @@ describe('repairSchemaDrift', () => { }); it('restores a missing FTS row map before later inserts, updates, and deletes', () => { - const db = new Database(':memory:'); + const db = createBetterSqliteDatabase(':memory:'); try { expect(repairSchemaDrift(db).residual).toEqual([]); db.prepare("INSERT INTO sessions(id, created_at, updated_at) VALUES ('s1', 1, 1)").run(); @@ -275,7 +276,7 @@ describe('repairSchemaDrift', () => { }); it('rebuilds stale FTS content while restoring a missing row map', () => { - const db = new Database(':memory:'); + const db = createBetterSqliteDatabase(':memory:'); try { expect(repairSchemaDrift(db).residual).toEqual([]); db.prepare("INSERT INTO sessions(id, created_at, updated_at) VALUES ('s1', 1, 1)").run(); @@ -302,4 +303,20 @@ describe('repairSchemaDrift', () => { db.close(); } }); + + it('CJK 分词后的健康库再跑 FTS 修复是 no-op', () => { + const db = createBetterSqliteDatabase(':memory:'); + try { + expect(repairSchemaDrift(db).residual).toEqual([]); + db.prepare("INSERT INTO sessions(id, created_at, updated_at) VALUES ('s1', 1, 1)").run(); + insertSearchMessage(db, 'm1', '登录报错了'); + expect(ftsRows(db).map((row) => row.content)).toEqual(['登 录 报 错 了']); + const before = ftsRows(db); + + expect(repairSchemaDrift(db).residual).toEqual([]); + expect(ftsRows(db)).toEqual(before); + } finally { + db.close(); + } + }); }); diff --git a/apps/desktop/src/main/__tests__/segmentMessagesFtsCjkMigration.test.ts b/apps/desktop/src/main/__tests__/segmentMessagesFtsCjkMigration.test.ts new file mode 100644 index 0000000000..26d4c65557 --- /dev/null +++ b/apps/desktop/src/main/__tests__/segmentMessagesFtsCjkMigration.test.ts @@ -0,0 +1,244 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; + +import { registerCjkSeg } from '../localDb/registerCjkSeg'; +import { repairMessagesFtsRows } from '../localDb/messagesFtsRowsRepair'; + +const { default: migration } = (await import( + '../../../drizzle/scripts/0100_segment_messages_fts_cjk' +)) as { default: { run: (db: Database.Database) => void } }; + +const FTS_ROLE_WHITELIST = "('user', 'assistant', 'ask_user', 'plan_review')"; + +interface FtsRow { + rowid: number; + message_id: string; + session_id: string; + role: string; + content: string; +} + +function setupLegacyDb(): Database.Database { + const db = new Database(':memory:'); + registerCjkSeg(db); + db.exec(` + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + agent_meta TEXT, + rewind_at INTEGER + ); + CREATE VIRTUAL TABLE messages_fts USING fts5( + message_id UNINDEXED, + session_id UNINDEXED, + role UNINDEXED, + content, + tokenize='porter unicode61' + ); + CREATE TABLE messages_fts_rows ( + fts_rowid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + message_id TEXT NOT NULL + ); + CREATE UNIQUE INDEX messages_fts_rows_message_id_idx + ON messages_fts_rows(message_id); + CREATE TRIGGER messages_fts_insert + AFTER INSERT ON messages + WHEN new.rewind_at IS NULL AND new.role IN ${FTS_ROLE_WHITELIST} + BEGIN + INSERT OR IGNORE INTO messages_fts_rows(message_id) VALUES (new.id); + INSERT OR REPLACE INTO messages_fts(rowid, message_id, session_id, role, content) + SELECT fts_rowid, new.id, new.session_id, new.role, new.content + FROM messages_fts_rows + WHERE message_id = new.id; + END; + `); + return db; +} + +function insertMessage( + db: Database.Database, + id: string, + role: string, + content: string, + rewindAt: number | null = null, +): void { + db.prepare( + `INSERT INTO messages(id, session_id, role, content, agent_meta, rewind_at) + VALUES (?, 's1', ?, ?, NULL, ?)`, + ).run(id, role, content, rewindAt); +} + +function ftsRows(db: Database.Database): FtsRow[] { + return db + .prepare( + `SELECT rowid, message_id, session_id, role, content + FROM messages_fts + ORDER BY rowid`, + ) + .all() as FtsRow[]; +} + +function matchIds(db: Database.Database, query: string): string[] { + return db + .prepare('SELECT message_id FROM messages_fts WHERE messages_fts MATCH ? ORDER BY message_id') + .all(query) + .map((row) => String((row as { message_id: unknown }).message_id)); +} + +describe('0100 segment messages_fts CJK', () => { + it('把连续汉字拆成按字 token,二字词可命中', () => { + const db = setupLegacyDb(); + insertMessage(db, 'm1', 'user', '登录报错了'); + insertMessage(db, 'm2', 'assistant', '边界检查失败'); + insertMessage(db, 'm3', 'user', 'login bug'); + expect(matchIds(db, '"登录"')).toEqual([]); + expect(matchIds(db, '"边界"')).toEqual([]); + + migration.run(db); + + expect(ftsRows(db).map((row) => row.content)).toEqual([ + '登 录 报 错 了', + '边 界 检 查 失 败', + 'login bug', + ]); + expect(matchIds(db, '"登 录"')).toEqual(['m1']); + expect(matchIds(db, '"边 界"')).toEqual(['m2']); + expect(matchIds(db, '"login"')).toEqual(['m3']); + db.close(); + }); + + it('健康新形态库上再跑 companion 与运行期修复都复用现有 FTS 行', () => { + const db = setupLegacyDb(); + insertMessage(db, 'm1', 'user', '登录报错了'); + insertMessage(db, 'm-drop', 'user', '临时'); + db.prepare("DELETE FROM messages WHERE id = 'm-drop'").run(); + insertMessage(db, 'm2', 'assistant', '边界'); + migration.run(db); + const before = ftsRows(db); + expect(before.some((row) => row.rowid > 1)).toBe(true); + + migration.run(db); + expect(ftsRows(db)).toEqual(before); + expect(matchIds(db, '"登 录"')).toEqual(['m1']); + + repairMessagesFtsRows(db); + expect(ftsRows(db)).toEqual(before); + expect(matchIds(db, '"边 界"')).toEqual(['m2']); + db.close(); + }); + + it('后续插入走 TEMP cjk_seg;旧连接漏注册仍能写原文', () => { + const db = setupLegacyDb(); + migration.run(db); + insertMessage(db, 'm-new', 'user', '边界'); + expect(matchIds(db, '"边 界"')).toEqual(['m-new']); + const insertTrigger = db + .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name='messages_fts_insert'") + .get() as { sql: string }; + expect(insertTrigger.sql).toContain('new.content'); + expect(insertTrigger.sql).toContain("pragma_function_list"); + expect(insertTrigger.sql).not.toContain('cjk_seg(new.content)'); + const tempInsert = db + .prepare("SELECT sql FROM sqlite_temp_master WHERE type='trigger' AND name='messages_fts_insert_cjk'") + .get() as { sql: string }; + expect(tempInsert.sql).toContain('cjk_seg(new.content)'); + db.close(); + + const bare = new Database(':memory:'); + bare.exec(` + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + rewind_at INTEGER + ); + CREATE VIRTUAL TABLE messages_fts USING fts5( + message_id UNINDEXED, + session_id UNINDEXED, + role UNINDEXED, + content, + tokenize='porter unicode61' + ); + CREATE TABLE messages_fts_rows ( + fts_rowid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + message_id TEXT NOT NULL + ); + `); + bare.exec(insertTrigger.sql); + expect(() => + bare + .prepare( + `INSERT INTO messages(id, session_id, role, content, rewind_at) + VALUES ('m1', 's1', 'user', '边界', NULL)`, + ) + .run(), + ).not.toThrow(); + expect(ftsRows(bare).map((row) => row.content)).toEqual(['边界']); + expect(matchIds(bare, '"边 界"')).toEqual([]); + expect(matchIds(bare, '"边界"')).toEqual(['m1']); + bare.close(); + }); + + it('文件库重开后新连接按字切、旧连接仍写原文', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'cindy-cjk-fts-rollback-')); + const dbPath = path.join(dir, 'chat.db'); + try { + const writer = new Database(dbPath); + registerCjkSeg(writer); + writer.exec(` + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + agent_meta TEXT, + rewind_at INTEGER + ); + CREATE VIRTUAL TABLE messages_fts USING fts5( + message_id UNINDEXED, + session_id UNINDEXED, + role UNINDEXED, + content, + tokenize='porter unicode61' + ); + CREATE TABLE messages_fts_rows ( + fts_rowid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + message_id TEXT NOT NULL + ); + CREATE UNIQUE INDEX messages_fts_rows_message_id_idx + ON messages_fts_rows(message_id); + `); + migration.run(writer); + insertMessage(writer, 'm-new', 'user', '登录'); + expect(matchIds(writer, '"登 录"')).toEqual(['m-new']); + writer.close(); + + const oldClient = new Database(dbPath); + insertMessage(oldClient, 'm-old', 'user', '边界'); + expect(ftsRows(oldClient).map((row) => row.content).sort()).toEqual(['登 录', '边界']); + expect(matchIds(oldClient, '"边 界"')).toEqual([]); + expect(matchIds(oldClient, '"边界"')).toEqual(['m-old']); + oldClient.close(); + + const newClient = new Database(dbPath); + registerCjkSeg(newClient); + insertMessage(newClient, 'm-again', 'user', '探针'); + expect(matchIds(newClient, '"探 针"')).toEqual(['m-again']); + expect(matchIds(newClient, '"登 录"')).toEqual(['m-new']); + newClient.close(); + } finally { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // Windows 上 WAL 伴随文件可能短暂占用,不影响断言。 + } + } + }); +}); diff --git a/apps/desktop/src/main/__tests__/windowsPackagedInstanceBarrier.test.ts b/apps/desktop/src/main/__tests__/windowsPackagedInstanceBarrier.test.ts index e5e7bb0ee8..d0caab923c 100644 --- a/apps/desktop/src/main/__tests__/windowsPackagedInstanceBarrier.test.ts +++ b/apps/desktop/src/main/__tests__/windowsPackagedInstanceBarrier.test.ts @@ -1,6 +1,7 @@ +import { EventEmitter } from 'node:events'; import os from 'node:os'; import path from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { acquireWindowsPackagedInstanceBarrier, @@ -9,6 +10,12 @@ import { describe('windowsPackagedInstanceBarrier', () => { it('parses only complete helper statuses', () => { + expect(__testing.parseBarrierStatus('{"status":"started"}')).toEqual({ + status: 'started', + }); + expect(__testing.parseBarrierStatus('{"status":"locked"}')).toEqual({ + status: 'locked', + }); expect(__testing.parseBarrierStatus('{"status":"acquired"}')).toEqual({ status: 'acquired', }); @@ -34,6 +41,39 @@ describe('windowsPackagedInstanceBarrier', () => { expect(__testing.WINDOWS_PACKAGED_INSTANCE_BARRIER_SCRIPT).toContain( 'CINDY_SINGLETON_WINDOW_TITLE', ); + // Add-Type 编译必须在 mutex 锁住之后才发生,否则冷启动会吃掉竞争预算。 + expect( + __testing.WINDOWS_PACKAGED_INSTANCE_BARRIER_SCRIPT.indexOf('{"status":"locked"}'), + ).toBeLessThan(__testing.WINDOWS_PACKAGED_INSTANCE_BARRIER_SCRIPT.indexOf('Add-Type')); + }); + + it('waits for the helper exit after forced termination', async () => { + const child = new EventEmitter() as EventEmitter & { + exitCode: number | null; + signalCode: NodeJS.Signals | null; + kill: ReturnType; + }; + child.exitCode = null; + child.signalCode = null; + child.kill = vi.fn(() => true); + let finished = false; + const waiting = __testing + .waitForExit(child as never, 20) + .then(() => { + finished = true; + }); + + await vi.waitFor(() => expect(child.kill).toHaveBeenCalledOnce(), { + // 20ms 的强制终止定时器在有负载的 runner 上可能晚触发; + // 只放宽观察窗口,不改变「exit 事件前不得 finish」的断言。 + timeout: 200, + interval: 5, + }); + expect(finished).toBe(false); + child.exitCode = 1; + child.emit('exit', 1, null); + await waiting; + expect(finished).toBe(true); }); it.runIf(process.platform === 'win32')( diff --git a/apps/desktop/src/main/localDb/__tests__/chatHistorySearch.pure.test.ts b/apps/desktop/src/main/localDb/__tests__/chatHistorySearch.pure.test.ts index 937d673c74..fb51f4aecb 100644 --- a/apps/desktop/src/main/localDb/__tests__/chatHistorySearch.pure.test.ts +++ b/apps/desktop/src/main/localDb/__tests__/chatHistorySearch.pure.test.ts @@ -5,7 +5,13 @@ import { describe, expect, it } from 'vitest'; -import { fuseRRF, buildFtsMatch, RRF_K } from '../chatHistorySearch.pure'; +import { + fuseRRF, + buildFtsMatch, + buildMessagesFtsMatch, + visibleTextMatchesMessagesFtsQuery, + RRF_K, +} from '../chatHistorySearch.pure'; describe('fuseRRF', () => { it('空输入返回空数组', () => { @@ -80,6 +86,17 @@ describe('buildFtsMatch', () => { expect(buildFtsMatch('登录报错')).toBe('"登录报错"'); }); + it('messages_fts 把 CJK run 收成相邻 phrase;默认多词 OR,session_search 显式 AND', () => { + expect(buildMessagesFtsMatch('登录报错')).toBe('"登 录 报 错"'); + expect(buildMessagesFtsMatch('边界')).toBe('"边 界"'); + expect(buildMessagesFtsMatch('login crash')).toBe('"login" OR "crash"'); + expect(buildMessagesFtsMatch('修复 login 问题')).toBe('"修 复" OR "login" OR "问 题"'); + expect(buildMessagesFtsMatch('login crash', 'AND')).toBe('"login" AND "crash"'); + expect(buildMessagesFtsMatch('修复 login 问题', 'AND')).toBe( + '"修 复" AND "login" AND "问 题"', + ); + }); + it('中英混合: 各自成 token', () => { expect(buildFtsMatch('修复 login 问题')).toBe('"修复" OR "login" OR "问题"'); }); @@ -89,4 +106,47 @@ describe('buildFtsMatch', () => { const out = buildFtsMatch(many)!; expect(out.split(' OR ')).toHaveLength(32); }); + + it('combining mark / 变体选择符跟着所属 token,不拆成两半', () => { + expect(buildMessagesFtsMatch('甲́乙')).toBe('"甲́ 乙"'); + expect(buildMessagesFtsMatch('禰\u{E0100}豆子')).toBe('"禰\u{E0100} 豆 子"'); + }); + + it('超长连续汉字截成有限 MATCH,不把整段扩进去', () => { + const out = buildMessagesFtsMatch('边'.repeat(400))!; + expect(out.length).toBeLessThanOrEqual(2048); + expect(out.startsWith('"边 ')).toBe(true); + }); + + it('65~256 字连续汉字整段保留,不截成 64 字前缀', () => { + const query = '边'.repeat(200) + '界'; + // messages_fts:cjk_seg 后 201 个单字相邻 phrase,整段精确召回。 + expect(buildMessagesFtsMatch(query)).toBe(`"${'边 '.repeat(200)}界"`); + // 群历史整段 token:完整段 quoted 精确匹配,前缀假阳性不再出现。 + expect(buildFtsMatch(query)).toBe(`"${query}"`); + }); + + it('超过 256 字的汉字 run 截到上限,只防御绕过 schema 的输入', () => { + expect(buildMessagesFtsMatch('边'.repeat(300))).toBe(`"${'边 '.repeat(255)}边"`); + }); + + it('超长 Latin / 数字 token 不截断,quoted MATCH 仍是精确命中', () => { + const hex = 'a'.repeat(128); + expect(buildMessagesFtsMatch(hex)).toBe(`"${hex}"`); + expect(buildFtsMatch(hex)).toBe(`"${hex}"`); + }); +}); + +describe('visibleTextMatchesMessagesFtsQuery', () => { + it('跨标点的按字 phrase 仍算可见命中', () => { + expect(visibleTextMatchesMessagesFtsQuery('边,界', '边界')).toBe(true); + expect(visibleTextMatchesMessagesFtsQuery('登录报错了', '登录')).toBe(true); + }); + + it('可见正文不含 query 时不算命中,即使隐藏字段会 MATCH', () => { + expect(visibleTextMatchesMessagesFtsQuery('please inspect the billing flow', 'secret')).toBe( + false, + ); + expect(visibleTextMatchesMessagesFtsQuery('结论。', 'turn17search1')).toBe(false); + }); }); diff --git a/apps/desktop/src/main/localDb/__tests__/chatHistorySearch.test.ts b/apps/desktop/src/main/localDb/__tests__/chatHistorySearch.test.ts index ea8856f712..2ec2bee928 100644 --- a/apps/desktop/src/main/localDb/__tests__/chatHistorySearch.test.ts +++ b/apps/desktop/src/main/localDb/__tests__/chatHistorySearch.test.ts @@ -178,6 +178,8 @@ describe('searchChatHistoryHybrid', () => { }); expect(dbMocks.query).toHaveBeenCalledTimes(1); + expect(dbMocks.query.mock.calls[0][0]).toContain('substr(m.content, 1, ?)'); + expect(dbMocks.query.mock.calls[0][1][0]).toBe(16_384); expect(dbMocks.query.mock.calls[0][1].at(-1)).toBe(51); expect(result.hits.map((hit) => hit.sessionId)).toEqual(['s2']); expect(result.nextOffset).toBeNull(); diff --git a/apps/desktop/src/main/localDb/__tests__/cjkFtsMatch.integration.test.ts b/apps/desktop/src/main/localDb/__tests__/cjkFtsMatch.integration.test.ts new file mode 100644 index 0000000000..5a77fcec84 --- /dev/null +++ b/apps/desktop/src/main/localDb/__tests__/cjkFtsMatch.integration.test.ts @@ -0,0 +1,76 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; + +import { buildMessagesFtsMatch } from '../chatHistorySearch.pure'; +import { cjkSeg } from '../cjkSeg'; +import { registerCjkSeg } from '../registerCjkSeg'; + +function setup() { + const db = new Database(':memory:'); + registerCjkSeg(db); + db.exec(` + CREATE VIRTUAL TABLE messages_fts USING fts5( + message_id UNINDEXED, session_id UNINDEXED, role UNINDEXED, content, + tokenize='porter unicode61' + ); + `); + return db; +} + +function add(db: Database.Database, id: string, content: string) { + db.prepare('INSERT INTO messages_fts(message_id, session_id, role, content) VALUES (?,?,?,?)').run( + id, + 's', + 'user', + cjkSeg(content), + ); +} + +function matchIds(db: Database.Database, query: string): string[] { + const match = buildMessagesFtsMatch(query); + if (!match) return []; + return db + .prepare('SELECT message_id FROM messages_fts WHERE messages_fts MATCH ? ORDER BY message_id') + .all(match) + .map((row) => String((row as { message_id: string }).message_id)); +} + +describe('messages_fts CJK match integration', () => { + it('汉字与 Latin/数字相邻时仍能按字召回', () => { + const db = setup(); + add(db, 'm1', 'foo登录bar'); + add(db, 'm2', 'login报错'); + add(db, 'm3', '登录报错了'); + expect(db.prepare('SELECT message_id, content FROM messages_fts ORDER BY message_id').all()).toEqual([ + { message_id: 'm1', content: 'foo 登 录 bar' }, + { message_id: 'm2', content: 'login 报 错' }, + { message_id: 'm3', content: '登 录 报 错 了' }, + ]); + expect(matchIds(db, '登录')).toEqual(['m1', 'm3']); + expect(matchIds(db, '报错')).toEqual(['m2', 'm3']); + expect(matchIds(db, 'login报错')).toEqual(['m2']); + expect(matchIds(db, 'login')).toEqual(['m2']); + db.close(); + }); + + it('侧栏默认 OR 最大化召回;session_search 的 AND 要求全部命中', () => { + const db = setup(); + add(db, 'a', '请先登录再提交'); + add(db, 'b', '报错了但没有登录'); + add(db, 'c', 'login timeout'); + add(db, 'd', 'fix the login crash now'); + expect(buildMessagesFtsMatch('login crash')).toBe('"login" OR "crash"'); + expect(matchIds(db, 'login crash')).toEqual(['c', 'd']); + expect(matchIds(db, '登录 报错')).toEqual(['a', 'b']); + expect(buildMessagesFtsMatch('login crash', 'AND')).toBe('"login" AND "crash"'); + expect( + db + .prepare('SELECT message_id FROM messages_fts WHERE messages_fts MATCH ? ORDER BY message_id') + .all(buildMessagesFtsMatch('login crash', 'AND')) + .map((row) => String((row as { message_id: string }).message_id)), + ).toEqual(['d']); + expect(matchIds(db, '登录')).toEqual(['a', 'b']); + expect(matchIds(db, 'login')).toEqual(['c', 'd']); + db.close(); + }); +}); diff --git a/apps/desktop/src/main/localDb/__tests__/cjkSeg.test.ts b/apps/desktop/src/main/localDb/__tests__/cjkSeg.test.ts new file mode 100644 index 0000000000..9d5f5d9ff1 --- /dev/null +++ b/apps/desktop/src/main/localDb/__tests__/cjkSeg.test.ts @@ -0,0 +1,196 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; + +import { buildSnippetFromContent, cjkSeg, SNIPPET_SOURCE_MAX_CHARS } from '../cjkSeg'; +import { registerCjkSeg } from '../registerCjkSeg'; +import { buildMessagesFtsMatch, extractMessagesFtsTokens } from '../chatHistorySearch.pure'; + +const CORPUS = [ + '登录报错了', + '边界', + 'login bug', + '修复 login 问题', + 'foo登录bar', + 'ひらがなカタカナ한글', + '登录,报错', + '边,界', + 'ABC', + '', + '探针', + '甲́乙', +] as const; + +describe('cjkSeg', () => { + it('只在连续汉字中间插空格', () => { + expect(cjkSeg('登录报错了')).toBe('登 录 报 错 了'); + expect(cjkSeg('边界')).toBe('边 界'); + expect(cjkSeg('login bug')).toBe('login bug'); + expect(cjkSeg('修复 login 问题')).toBe('修 复 login 问 题'); + expect(cjkSeg('foo登录bar')).toBe('foo 登 录 bar'); + expect(cjkSeg('login报错')).toBe('login 报 错'); + expect(cjkSeg('甲́乙')).toBe('甲́ 乙'); + }); + + it('不处理假名、谚文、全角标点', () => { + expect(cjkSeg('ひらがな')).toBe('ひらがな'); + expect(cjkSeg('カタカナ')).toBe('カタカナ'); + expect(cjkSeg('한글')).toBe('한글'); + expect(cjkSeg('登录,报错')).toBe('登 录,报 错'); + expect(cjkSeg('ABC')).toBe('ABC'); + }); + + it('空串与 null 保持原样', () => { + expect(cjkSeg('')).toBe(''); + expect(cjkSeg(null)).toBeNull(); + expect(cjkSeg(undefined)).toBeNull(); + }); +}); + +describe('buildSnippetFromContent', () => { + it('插入空格不出现在 snippet,原文空格原样保留(P1 回归)', () => { + // 中英相邻:不得多出假空格。 + expect(buildSnippetFromContent('foo登录bar', ['foo登录bar'])).toBe('foo登录bar'); + // 原文真实空格:不得被吞。 + expect(buildSnippetFromContent('登录 报错', ['登录'])).toBe('登录 报错'); + // 多命中区间分开高亮;英文 token 独立命中。 + expect(buildSnippetFromContent('foo登录bar后 login 失败', ['foo登录bar', 'login'])).toBe( + 'foo登录barlogin 失败', + ); + expect(buildSnippetFromContent('纯英文 hello world', ['hello'])).toBe( + '纯英文 hello world', + ); + // 星光面字符在命中位置之前:必须按码点下标定位,不能用 UTF-16 indexOf。 + expect(buildSnippetFromContent('😀登录报错', ['登录'])).toBe('😀登录报错'); + expect(buildSnippetFromContent('😀😀甲乙', ['甲乙'])).toBe('😀😀甲乙'); + // 英文大小写折叠:召回不区分大小写,高亮也应跟上。 + expect(buildSnippetFromContent('please login now', ['Login'])).toBe( + 'please login now', + ); + // 原文里的真 必须转义,不能被消费端当成控制标记。 + expect(buildSnippetFromContent('see here 登录', ['登录'])).toBe( + 'see <mark>here</mark> 登录', + ); + expect(buildSnippetFromContent('a & b < c', ['a'])).toBe( + 'a & b < c', + ); + // 窗边缘空格是原文的一部分,不能在生成端丢掉。 + expect(buildSnippetFromContent(' 登录 ', ['登录'])).toBe( + ' 登录 ', + ); + }); + + it('跨很长间距的两处命中只保留一段窗口,不返回整条消息', () => { + const long = `needle${'占'.repeat(400)}needle`; + const snippet = buildSnippetFromContent(long, ['needle']); + expect(snippet.length).toBeLessThan(200); + expect(snippet).toContain('needle'); + expect(snippet.startsWith('needle') || snippet.endsWith('needle')).toBe(true); + }); + + it('长文本截窗 + 省略号;空内容与未命中退化为纯文本恒返回 string', () => { + const long = `前缀${'占'.repeat(40)}登录报错${'尾'.repeat(40)}`; + const snippet = buildSnippetFromContent(long, ['登录']); + expect(snippet).toContain('登录'); + expect(snippet.startsWith('…')).toBe(true); + expect(snippet.endsWith('…')).toBe(true); + expect(buildSnippetFromContent('', [])).toBe(''); + expect(buildSnippetFromContent(null, ['任意'])).toBe(''); + // 未命中不打高亮、不截出假空格。 + expect(buildSnippetFromContent('很长的原文内容'.repeat(10), ['不存在词'])).not.toContain(''); + expect(buildSnippetFromContent('短文本', ['任意'])).toBe('短文本'); + }); + + it('超长原文只扫前缀,尾部命中退回文首 fallback', () => { + const long = `${'前'.repeat(SNIPPET_SOURCE_MAX_CHARS)}登录`; + const snippet = buildSnippetFromContent(long, ['登录']); + expect(snippet).not.toContain(''); + expect(snippet.startsWith('前')).toBe(true); + expect(snippet.endsWith('…')).toBe(true); + }); + + it('真实 FTS 链路:snippet 不篡改中英相邻与原文真实空格', () => { + const db = new Database(':memory:'); + registerCjkSeg(db); + db.exec(` + CREATE VIRTUAL TABLE messages_fts USING fts5( + message_id UNINDEXED, session_id UNINDEXED, role UNINDEXED, content, + tokenize='porter unicode61' + ); + `); + try { + const add = (id: string, content: string) => { + db.prepare( + 'INSERT INTO messages_fts(message_id, session_id, role, content) VALUES (?,?,?,?)', + ).run(id, 's', 'user', cjkSeg(content)); + }; + add('m1', 'foo登录bar'); + add('m2', '登录 报错了'); + + for (const [query, id, orig, expectSnippet] of [ + ['foo登录bar', 'm1', 'foo登录bar', 'foo登录bar'], + // 「登录 报错」拆成两个 token,命中区间各自高亮。 + ['登录 报错', 'm2', '登录 报错了', '登录 报错了'], + // 单 token 整段命中:mark 覆盖整个相邻 run(含其中的原文空格)。 + ['登录报错了', 'm2', '登录 报错了', '登录 报错了'], + ] as const) { + // 真 MATCH 命中该行 —— 证明 snippet 用的正是召回链路的同一份 token 口径。 + const hit = db + .prepare('SELECT message_id FROM messages_fts WHERE messages_fts MATCH ?') + .all(buildMessagesFtsMatch(query)) + .map((r) => String((r as { message_id: string }).message_id)); + expect(hit).toContain(id); + expect(buildSnippetFromContent(orig, extractMessagesFtsTokens(query))).toBe(expectSnippet); + } + } finally { + db.close(); + } + }); +}); + +describe('cjkSeg 跨副本一致性', () => { + it('0099 companion 内嵌快照与正本输出一致', async () => { + const { default: migration } = (await import( + '../../../../drizzle/scripts/0100_segment_messages_fts_cjk' + )) as { default: { run: (db: Database.Database) => void } }; + const db = new Database(':memory:'); + try { + migration.run(db); + const sqlSeg = db.prepare('SELECT cjk_seg(?) AS v'); + for (const sample of CORPUS) { + expect((sqlSeg.get(sample) as { v: string | null }).v).toBe(cjkSeg(sample)); + } + expect((sqlSeg.get(null) as { v: string | null }).v).toBe(cjkSeg(null)); + } finally { + db.close(); + } + }); + + it('inline worker 内嵌副本与正本输出一致', () => { + const source = readFileSync( + path.resolve(__dirname, '../client/WorkerThreadTransport.ts'), + 'utf8', + ); + const start = source.indexOf('function registerCjkSeg(nextDb) {'); + const end = source.indexOf('\nfunction hashMigrationFile', start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + const restored = source.slice(start, end).replaceAll(String.raw`\\p`, String.raw`\p`); + const register = new Function( + `"use strict"; ${restored}; return registerCjkSeg;`, + )() as (db: Database.Database) => void; + const db = new Database(':memory:'); + try { + register(db); + const sqlSeg = db.prepare('SELECT cjk_seg(?) AS v'); + for (const sample of CORPUS) { + expect((sqlSeg.get(sample) as { v: string | null }).v).toBe(cjkSeg(sample)); + } + expect((sqlSeg.get(null) as { v: string | null }).v).toBe(cjkSeg(null)); + } finally { + db.close(); + } + }); +}); diff --git a/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts b/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts index 472f32bd0c..061422032e 100644 --- a/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts +++ b/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts @@ -272,6 +272,16 @@ describe('conversationSearch.pure', () => { }); }); + it('preview / snippet 窗口按码点截,不把 emoji 切成半个代理对', () => { + const emoji = `${'😀'.repeat(90)}登录`; + const preview = normalizeConversationContentPreview('user', emoji, '登录', 180); + const lone = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { expect(visibleMessageTextForConversationSearch('ask_user', { questions: [{ question: 'Which branch should I use?' }], diff --git a/apps/desktop/src/main/localDb/__tests__/conversationSearch.test.ts b/apps/desktop/src/main/localDb/__tests__/conversationSearch.test.ts index 545d644acf..40871dced3 100644 --- a/apps/desktop/src/main/localDb/__tests__/conversationSearch.test.ts +++ b/apps/desktop/src/main/localDb/__tests__/conversationSearch.test.ts @@ -45,4 +45,10 @@ describe('conversationSearch source invariants', () => { 'filters.sessionIds !== null || filters.workingDirs !== null', ); }); + + it('keeps FTS hits only when visible text matches, not merely because preview is non-empty', () => { + expect(conversationSearchSource).toContain('visibleTextMatchesMessagesFtsQuery'); + expect(conversationSearchSource).toContain('preview.keywordMatchedVisibleText'); + expect(conversationSearchSource).not.toContain('preview.preview.length === 0 ? null : hit.ftsRank'); + }); }); diff --git a/apps/desktop/src/main/localDb/__tests__/migrationReplay.test.ts b/apps/desktop/src/main/localDb/__tests__/migrationReplay.test.ts index 86e6f96093..117fcfe7dc 100644 --- a/apps/desktop/src/main/localDb/__tests__/migrationReplay.test.ts +++ b/apps/desktop/src/main/localDb/__tests__/migrationReplay.test.ts @@ -161,6 +161,22 @@ describeMigrationReplay('migration replay', () => { expect(triggerExists(db, 'schedule_session_latest_run_update')).toBe(true); expect(unreadRunPlan.some((row) => row.detail.includes('idx_schedule_runs_unread_terminal'))) .toBe(true); + + db.prepare("INSERT INTO sessions (id, created_at, updated_at) VALUES ('s-cjk', 1, 1)").run(); + db.prepare( + `INSERT INTO messages (id, client_id, session_id, role, content, created_at) + VALUES ('m-cjk', 'c-cjk', 's-cjk', 'user', '登录报错了', 1)`, + ).run(); + const indexed = db + .prepare('SELECT content FROM messages_fts WHERE message_id = ?') + .get('m-cjk') as { content: string }; + expect(indexed.content).toBe('登 录 报 错 了'); + expect( + db + .prepare("SELECT message_id FROM messages_fts WHERE messages_fts MATCH '\"登 录\"'") + .pluck() + .all(), + ).toEqual(['m-cjk']); } finally { cleanup(); } diff --git a/apps/desktop/src/main/localDb/betterSqliteFactory.ts b/apps/desktop/src/main/localDb/betterSqliteFactory.ts index 71847f24f6..2234bbf93c 100644 --- a/apps/desktop/src/main/localDb/betterSqliteFactory.ts +++ b/apps/desktop/src/main/localDb/betterSqliteFactory.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import Database from 'better-sqlite3'; import { createLogger, maskPath } from '../logger'; +import { assertCjkSegRegistered, registerCjkSeg } from './registerCjkSeg'; const log = createLogger('localDb/betterSqliteFactory'); @@ -129,6 +130,8 @@ export function createBetterSqliteDatabase( // 打开成功后再收紧一次:覆盖本次刚创建的新库,以及 WAL pragma 之前 / 之后 SQLite // 创建的 -wal / -shm 伴随文件(继承主库 0600)。 restrictDbFilePermissions(filename); + registerCjkSeg(db); + assertCjkSegRegistered(db); return db; } diff --git a/apps/desktop/src/main/localDb/chatHistorySearch.pure.ts b/apps/desktop/src/main/localDb/chatHistorySearch.pure.ts index f2b5cd19b9..4b67e816f7 100644 --- a/apps/desktop/src/main/localDb/chatHistorySearch.pure.ts +++ b/apps/desktop/src/main/localDb/chatHistorySearch.pure.ts @@ -7,6 +7,8 @@ * 主引擎 chatHistorySearch.ts import 这里的函数。 */ +import { cjkSeg } from './cjkSeg'; + /** RRF 平滑常数(业界惯用 60); 越大越削弱头部排名优势。 */ export const RRF_K = 60; @@ -50,17 +52,133 @@ export function fuseRRF( ); } +const FTS_TOKEN_CAP = 32; +/** + * 单个 token 码点数上限,与 MCP schema 的 query 上限(256)对齐。连续汉字在 + * 口径内整段保留才能整段精确召回——截成前缀会让只含前缀的消息假阳性命中 + * (messages_fts 按字 phrase 与群历史整段 token 两条路径都是)。帽只防御 + * 绕过 schema 的超长侧栏输入。 + */ +const FTS_TOKEN_CHAR_CAP = 256; +/** 最终 MATCH 表达式字符上限,挡住超长 CJK phrase 在进 SQLite 前就把 heap 打爆。 */ +const FTS_MATCH_CHAR_CAP = 2_048; +/** + * 字母/数字 run,附着的 combining mark / 变体选择符跟着所属 token, + * 不单独切开。与写入侧 cjkSeg「mark 不阻断分词」对齐。 + */ +const FTS_TOKEN_RE = /[\p{L}\p{N}][\p{L}\p{N}\p{M}\u{E0100}-\u{E01EF}]*/gu; + +/** + * 与 {@link buildMessagesFtsMatch} 同源的 token 抽取(去重 + 上限)。 + * buildSnippetFromContent 用同一份 token 在原文上定位高亮区间, + * 保证「召回用什么词、snippet 就标什么词」。 + */ +export function extractMessagesFtsTokens(query: string): string[] { + const tokens = query.match(FTS_TOKEN_RE); + if (!tokens || tokens.length === 0) return []; + const capped = tokens + .map((token) => { + const chars = [...token]; + // 超长 token 截到上限防御病态输入;Latin / 数字整段保留(截断后 quoted + // MATCH 不再是精确命中,例如 128 位 hex)。汉字 run 在 schema 口径内整段保留。 + if (chars.length <= FTS_TOKEN_CHAR_CAP) { + return token; + } + return chars.slice(0, FTS_TOKEN_CHAR_CAP).join(''); + }) + .filter((token) => token.length > 0); + return [...new Set(capped)].slice(0, FTS_TOKEN_CAP); +} + +/** + * 把一段可见文本收成与 messages_fts 写入侧接近的 token 序列:先 cjk_seg, + * 再按字母/数字/汉字 run 切开,并小写。用于判断 FTS 命中是不是落在用户看得见的字上。 + */ +export function tokenizeVisibleMessagesFtsText(text: string): string[] { + const segmented = cjkSeg(text) ?? text; + const tokens = segmented.match(FTS_TOKEN_RE); + if (!tokens || tokens.length === 0) return []; + return tokens.map((token) => token.toLowerCase()); +} + +function indexOfTokenSequence(haystack: readonly string[], needle: readonly string[]): number { + if (needle.length === 0 || needle.length > haystack.length) return -1; + outer: for (let i = 0; i <= haystack.length - needle.length; i += 1) { + for (let j = 0; j < needle.length; j += 1) { + if (haystack[i + j] !== needle[j]) continue outer; + } + return i; + } + return -1; +} + +/** + * 侧栏用:query 的 messages_fts token 是否作为相邻序列出现在可见文本里。 + * 能保住「边,界」这种按字 phrase,但不会把附件文件名、内部 citation 当成可见命中。 + * 不含 porter 词干(那是已知限制)。 + */ +export function visibleTextMatchesMessagesFtsQuery(visibleText: string, query: string): boolean { + if (!visibleText) return false; + const haystack = tokenizeVisibleMessagesFtsText(visibleText); + if (haystack.length === 0) return false; + for (const token of extractMessagesFtsTokens(query)) { + const needle = tokenizeVisibleMessagesFtsText(token); + if (needle.length === 0) continue; + if (indexOfTokenSequence(haystack, needle) >= 0) return true; + } + return false; +} + +function quoteFtsToken(token: string): string { + return `"${token.replace(/"/g, '""')}"`; +} + /** * 把自然语言 query 转成 FTS5 MATCH 表达式: 抽取字母/数字/CJK 连续 run 作为 token, * 每个 token 用双引号包裹(中和 FTS 操作符 - * : ( ) " 等防注入/语法错), OR 连接 * 最大化词法召回(相关度交给 bm25 + RRF 裁决)。无有效 token → null(跳过 FTS arm)。 * - * 注: messages_fts 用 porter unicode61 分词, CJK 连续串会被当作整体 token, - * 故 CJK 的细粒度词法匹配有限——这正是向量 arm 补位的场景。 + * 这是 unicode61 整段 token 形态, 给尚未按字切分的 FTS 表用(群历史)。 + * messages_fts 请用 {@link buildMessagesFtsMatch}。 */ export function buildFtsMatch(query: string): string | null { - const tokens = query.match(/[\p{L}\p{N}]+/gu); - if (!tokens || tokens.length === 0) return null; - const uniq = [...new Set(tokens)].slice(0, 32); - return uniq.map((t) => `"${t.replace(/"/g, '""')}"`).join(' OR '); + const uniq = extractMessagesFtsTokens(query); + if (uniq.length === 0) return null; + return joinQuotedTokens(uniq.map(quoteFtsToken), 'OR'); +} + +/** + * messages_fts 查询构造: 与写入侧 `cjk_seg` 对齐。 + * CJK run 收成相邻 phrase(`"边 界"`),英文 / 数字仍是独立 quoted token。 + * + * 多词连接符由入口决定,不是 FTS5 默认值: + * - 侧栏 / search_chat_history 走 OR,最大化词法召回,相关度交给 bm25 + RRF + * (与改 CJK 分词前同一口径,也是本 issue 要改进召回的那条路径)。 + * - session_search 走 AND:旧实现是整句单 phrase,AND 比整句更宽、仍要求 + * 各词都在同一条消息里,适合 LLM 精确回忆。 + * + * FTS5 隐式连接符本来就是 AND(`"login" "bug"` 与 `"login" AND "bug"` 等价), + * 所以 OR 必须显式写出;AND 显式写出仅为和 OR 对照、可读。 + */ +export function buildMessagesFtsMatch( + query: string, + join: 'AND' | 'OR' = 'OR', +): string | null { + const tokens = extractMessagesFtsTokens(query); + if (tokens.length === 0) return null; + return joinQuotedTokens( + tokens.map((token) => quoteFtsToken(cjkSeg(token) ?? token)), + join, + ); +} + +function joinQuotedTokens(quoted: readonly string[], join: 'AND' | 'OR'): string | null { + const sep = ` ${join} `; + let out = ''; + for (const part of quoted) { + const next = out ? `${out}${sep}${part}` : part; + if (next.length > FTS_MATCH_CHAR_CAP) break; + out = next; + } + return out.length > 0 ? out : null; } diff --git a/apps/desktop/src/main/localDb/chatHistorySearch.ts b/apps/desktop/src/main/localDb/chatHistorySearch.ts index 33f7d2139b..793f967edb 100644 --- a/apps/desktop/src/main/localDb/chatHistorySearch.ts +++ b/apps/desktop/src/main/localDb/chatHistorySearch.ts @@ -40,7 +40,8 @@ import type { import { getDbClient } from './client/current'; import { messages as messagesTable, sessions as sessionsTable } from './schema'; import { messageToCamel } from './mapper'; -import { fuseRRF, buildFtsMatch } from './chatHistorySearch.pure'; +import { fuseRRF, buildMessagesFtsMatch, extractMessagesFtsTokens } from './chatHistorySearch.pure'; +import { buildSnippetFromContent, SNIPPET_SOURCE_MAX_CHARS } from './cjkSeg'; import { resolveStoredWorkingDirCandidates } from './workingDirHistoryFilter'; import { createLogger } from '../logger'; import { getEmbeddingService } from '../embedding-host'; @@ -63,8 +64,6 @@ const VEC_OVERFETCH = 5; /** 融合候选池硬上限 —— offset 游标只在池内翻页; 超出则 poolCapped=true 提示用户缩范围。 */ const FUSE_POOL_CAP = 100; const MAX_INTERNAL_POOL = 500; -/** snippet 高亮上下文 token 半径(与 session-search.ts 对齐)。 */ -const SNIPPET_RADIUS = 8; interface ArmRow { messageId: string; @@ -226,15 +225,20 @@ async function runFtsArm( args: SearchChatHistoryEngineArgs, workdirCandidates: string[] | null, ): Promise { - const match = buildFtsMatch(args.query); + const queryTokens = extractMessagesFtsTokens(args.query); + const match = buildMessagesFtsMatch(args.query, 'OR'); if (!match) return []; const { clause, params } = buildFilterClause(args, workdirCandidates); + // snippet 从原文 m.content 重建,不用索引侧文本: + // messages_fts.content 是 cjk_seg 插过空格的形态,直接展示会篡改原文空格 + // (「foo登录bar」多出假空格、「登录 报错」的真空格被吃)。FTS5 无 offsets(), + // 命中位置由 buildSnippetFromContent 用同一份查询 token 在原文对齐的索引串里重算。 const sql = ` SELECT m.id AS messageId, m.session_id AS sessionId, m.role AS role, m.created_at AS createdAt, - snippet(messages_fts, -1, '', '', '…', ${SNIPPET_RADIUS}) AS snippet + substr(m.content, 1, ?) AS content FROM messages_fts JOIN messages m ON m.id = messages_fts.message_id JOIN sessions s ON s.id = m.session_id @@ -249,14 +253,14 @@ async function runFtsArm( sessionId: string; role: string; createdAt: number; - snippet: string; - }>(sql, [match, ...params, limit]); + content: string; + }>(sql, [SNIPPET_SOURCE_MAX_CHARS, match, ...params, limit]); return rows.map((r) => ({ messageId: r.messageId, sessionId: r.sessionId, role: r.role, createdAt: r.createdAt, - snippet: r.snippet, + snippet: buildSnippetFromContent(r.content, queryTokens), distance: null, })); } catch (e) { diff --git a/apps/desktop/src/main/localDb/cjkSeg.ts b/apps/desktop/src/main/localDb/cjkSeg.ts new file mode 100644 index 0000000000..14f6a5e814 --- /dev/null +++ b/apps/desktop/src/main/localDb/cjkSeg.ts @@ -0,0 +1,277 @@ +/** + * CJK 字间插空格 —— messages_fts 的写入侧与查询侧共用规格。 + * + * 覆盖范围只收 Unicode Han 字(`\p{Script=Han}`)。假名、谚文、全角折叠、 + * 标点都不处理;英文 / 数字 / 空白原样保留。改这个函数 = 必须配新的重建 + * migration,否则新旧行的索引形态会分裂,一致性判据会误报。 + * + * 写入侧:连续汉字中间插空格,并在汉字与相邻 Latin/数字之间也插空格, + * 让 FTS5 unicode61 把每个汉字当成独立 token,且不与英文粘成一个 token。 + * combining mark(`\\p{M}`)附着在前一个汉字上,不阻断分词。 + * 查询侧:同一规则把 CJK run 收成 phrase(`"边 界"`),要求相邻且有序。 + * + * 已知限制:汉字被标点隔开时(如「边,界」)unicode61 会产出相邻 token, + * 查询 phrase `"边 界"` 仍可能命中。这是按字索引的固有噪声(微信 VerbatimTokenizer + * 同款),不是 bug;收窄覆盖范围不会消除它。 + */ + +export const CJK_SEG_SQL_FN = 'cjk_seg'; + +const HAN_CHAR_RE = /\p{Script=Han}/u; +const LETTER_OR_NUMBER_RE = /[\p{L}\p{N}]/u; +const MARK_RE = /\p{M}/u; + +function isHanCodePoint(ch: string): boolean { + return HAN_CHAR_RE.test(ch); +} + +function isLetterOrNumber(ch: string): boolean { + return LETTER_OR_NUMBER_RE.test(ch); +} + +function isMark(ch: string): boolean { + return MARK_RE.test(ch); +} + +export function cjkSeg(input: unknown): string | null { + if (input == null) return null; + const text = typeof input === 'string' ? input : String(input); + if (text.length === 0) return text; + const chars = [...text]; + let out = ''; + let lastBoundary = ''; + for (let i = 0; i < chars.length; i += 1) { + const ch = chars[i]; + if (isMark(ch)) { + out += ch; + continue; + } + if (lastBoundary) { + const hanNow = isHanCodePoint(ch); + const hanPrev = isHanCodePoint(lastBoundary); + if ( + (hanNow && hanPrev) || + (hanNow && isLetterOrNumber(lastBoundary)) || + (hanPrev && isLetterOrNumber(ch)) + ) { + out += ' '; + } + } + out += ch; + lastBoundary = ch; + } + return out; +} + +/** + * cjkSeg 的逐字符映射版:输出与 cjkSeg 完全一致,同时给出与原文的逐字符对齐 + * 表(segChars[k] ↔ 原文码点下标 segToOrig[k];插入空格处为 -1)。 + * + * buildSnippetFromContent 用它在原文对齐的索引串里定位查询 token 的分词形态、 + * 再映回原文坐标打高亮;不能用「比对空格字符」做对齐,否则插入空格撞上原文 + * 真实空格时会错位。 + */ +export function cjkSegAligned( + input: unknown, +): { segChars: string[]; segToOrig: Int32Array } | null { + if (input == null || typeof input !== 'string') return null; + if (input.length === 0) return { segChars: [], segToOrig: new Int32Array(0) }; + const chars = [...input]; + const segChars: string[] = []; + const segToOrig: number[] = []; + let lastBoundary = ''; + for (let i = 0; i < chars.length; i += 1) { + const ch = chars[i]!; + if (isMark(ch)) { + segChars.push(ch); + segToOrig.push(i); + continue; + } + if (lastBoundary) { + const hanNow = isHanCodePoint(ch); + const hanPrev = isHanCodePoint(lastBoundary); + if ( + (hanNow && hanPrev) || + (hanNow && isLetterOrNumber(lastBoundary)) || + (hanPrev && isLetterOrNumber(ch)) + ) { + segChars.push(' '); + segToOrig.push(-1); + } + } + segChars.push(ch); + segToOrig.push(i); + lastBoundary = ch; + } + return { segChars, segToOrig: Int32Array.from(segToOrig) }; +} + +/** 在码点数组上找 needle,返回码点下标;不用 String.indexOf(那是 UTF-16 单元下标)。 */ +function indexOfCodePoints(haystack: readonly string[], needle: readonly string[], from: number): number { + if (needle.length === 0 || from > haystack.length - needle.length) return -1; + for (let i = from; i <= haystack.length - needle.length; i += 1) { + let matched = true; + for (let j = 0; j < needle.length; j += 1) { + if (haystack[i + j] !== needle[j]) { + matched = false; + break; + } + } + if (matched) return i; + } + return -1; +} + +export function isHanChar(ch: string): boolean { + return HAN_CHAR_RE.test(ch); +} + +const SNIPPET_CONTEXT_CHARS = 24; +/** snippet 窗口硬上限(码点数,含上下文)。跨命中不再拉整条超长消息。 */ +const SNIPPET_MAX_CHARS = SNIPPET_CONTEXT_CHARS * 2 + 32; +/** + * 重建 snippet 时扫描 / 经 worker 传回的最大字符数。超长消息只看前缀, + * 命中落在此前缀之后时退回文首 fallback,召回本身不受影响。 + * SQLite 对 TEXT 的 substr() 按 UTF-8 字符计,和 JS 码点扫描对齐。 + */ +export const SNIPPET_SOURCE_MAX_CHARS = 16_384; + +/** + * 从原文 content 上重建搜索 snippet:在「与原文逐字符对齐的分词形态」里定位 + * 查询 token 的分词形态,把命中区间映回原文坐标,打 `` 并按上下文截窗。 + * + * 为什么不能用 SQLite snippet():它返回的是 messages_fts.content——被 cjk_seg + * 插过空格的索引形态,直接展示会篡改原文(「foo登录bar」多出假空格、「登录 报错」 + * 的真空格被折叠吞掉)。也为什么不用 offsets():那是 FTS3/4 函数,FTS5 没有。 + * 写入侧 cjkSeg 只插空格不删改字符,因此拿查询 token 在 cjkSegAligned 的索引串里 + * 定位即可精确还原命中位置,且 snippet 里每个空格都来自用户原文。 + * + * 恒返回非空 string(找不到命中时退化为纯文本前窗),满足 SessionSearchHit.snippet: + * string 契约。`` 是本函数添加的哨兵;原文里的 `<` / `>` / `&` 先转成实体, + * 消费端按实体还原,避免用户内容里的真 `` 被当成控制标记。 + */ +export function buildSnippetFromContent(content: unknown, queryTokens: readonly string[]): string { + const raw = typeof content === 'string' ? content : content == null ? '' : String(content); + if (!raw) return ''; + const text = takeCodePoints(raw, SNIPPET_SOURCE_MAX_CHARS); + + const aligned = cjkSegAligned(text); + if (!aligned || aligned.segChars.length === 0) return fallbackSnippet(text); + const { segChars, segToOrig } = aligned; + const foldedSeg = segChars.map((ch) => ch.toLowerCase()); + + // 把每个查询 token 的分词形态在码点数组里定位成 [segStart, segEnd) 区间, + // 再经 segToOrig 映回原文码点区间(跳过 -1 的插入空格位)。 + // 查找全程用码点下标,不能用 String.indexOf / .length(那是 UTF-16 单元)。 + const spans: Array<{ start: number; end: number }> = []; + for (const token of queryTokens) { + const needle = cjkSeg(token); + if (!needle || needle.length === 0) continue; + const needleChars = [...needle]; + const foldedNeedle = needleChars.map((ch) => ch.toLowerCase()); + let from = 0; + for (;;) { + const at = indexOfCodePoints(foldedSeg, foldedNeedle, from); + if (at < 0) break; + from = at + needleChars.length; + const first = segToOrig[at]!; + const last = segToOrig[at + needleChars.length - 1]!; + if (first < 0 || last < 0) continue; + const start = Math.min(first, last); + const end = Math.max(first, last) + 1; + spans.push({ start, end }); + } + } + + const origChars = [...text]; + if (spans.length === 0) return fallbackSnippet(text); + + spans.sort((a, b) => a.start - b.start || a.end - b.end); + const merged: Array<{ start: number; end: number }> = []; + for (const span of spans) { + const prev = merged[merged.length - 1]; + if (prev && span.start <= prev.end) prev.end = Math.max(prev.end, span.end); + else merged.push({ ...span }); + } + + // 取最紧凑的一段命中簇,窗口硬上限,避免「首命中到末命中」把整条超长消息拉回来。 + const cluster = pickCompactSnippetCluster(merged, SNIPPET_MAX_CHARS); + const hitStart = cluster[0]!.start; + const hitEnd = cluster[cluster.length - 1]!.end; + let winStart = Math.max(0, hitStart - SNIPPET_CONTEXT_CHARS); + let winEnd = Math.min(origChars.length, hitEnd + SNIPPET_CONTEXT_CHARS); + if (winEnd - winStart > SNIPPET_MAX_CHARS) { + winEnd = Math.min(origChars.length, winStart + SNIPPET_MAX_CHARS); + if (winEnd < hitEnd) { + winEnd = Math.min(origChars.length, hitEnd); + winStart = Math.max(0, winEnd - SNIPPET_MAX_CHARS); + } + } + + let out = ''; + if (winStart > 0) out += '…'; + let cursor = winStart; + for (const span of cluster) { + const s = Math.max(span.start, winStart); + const e = Math.min(span.end, winEnd); + if (e <= cursor) continue; + out += escapeSnippetText(origChars.slice(cursor, s).join('')); + out += `${escapeSnippetText(origChars.slice(s, e).join(''))}`; + cursor = e; + } + out += escapeSnippetText(origChars.slice(cursor, winEnd).join('')); + if (winEnd < origChars.length) out += '…'; + return out; + + function fallbackSnippet(t: string): string { + const chars = [...t]; + if (chars.length <= SNIPPET_CONTEXT_CHARS * 2) return escapeSnippetText(t); + return `${escapeSnippetText(chars.slice(0, SNIPPET_CONTEXT_CHARS).join(''))}…`; + } +} + +/** + * snippet 走 `` 哨兵协议。原文里的 `<` / `>` 必须先转成实体, + * 否则用户内容里的真 `` / `` 会被消费端当成控制标记, + * 把原文改写成高亮片段。`&` 也一并转义,避免二次解析把实体还原错。 + */ +function escapeSnippetText(text: string): string { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} + +function takeCodePoints(text: string, max: number): string { + const chars: string[] = []; + for (const ch of text) { + if (chars.length >= max) return chars.join(''); + chars.push(ch); + } + return text; +} + +function pickCompactSnippetCluster( + merged: Array<{ start: number; end: number }>, + maxChars: number, +): Array<{ start: number; end: number }> { + if (merged.length <= 1) return merged; + let bestStart = 0; + let bestEnd = 0; + let bestSpan = Number.POSITIVE_INFINITY; + let j = 0; + for (let i = 0; i < merged.length; i += 1) { + while (j + 1 < merged.length && merged[j + 1]!.end - merged[i]!.start <= maxChars) { + j += 1; + } + const span = merged[j]!.end - merged[i]!.start; + const count = j - i; + const bestCount = bestEnd - bestStart; + if (count > bestCount || (count === bestCount && span < bestSpan)) { + bestStart = i; + bestEnd = j; + bestSpan = span; + } + } + return merged.slice(bestStart, bestEnd + 1); +} diff --git a/apps/desktop/src/main/localDb/client/WorkerThreadTransport.ts b/apps/desktop/src/main/localDb/client/WorkerThreadTransport.ts index d9122b7798..0ace3791ee 100644 --- a/apps/desktop/src/main/localDb/client/WorkerThreadTransport.ts +++ b/apps/desktop/src/main/localDb/client/WorkerThreadTransport.ts @@ -63,6 +63,68 @@ function applyPragmas(nextDb) { nextDb.pragma('busy_timeout = 5000'); } +function registerCjkSeg(nextDb) { + const hanChar = /\\p{Script=Han}/u; + const letterOrNumber = /[\\p{L}\\p{N}]/u; + const mark = /\\p{M}/u; + const whitelist = "('user', 'assistant', 'ask_user', 'plan_review')"; + nextDb.function('cjk_seg', { deterministic: true }, (value) => { + if (value == null) return null; + const text = typeof value === 'string' ? value : String(value); + if (text.length === 0) return text; + const chars = Array.from(text); + let out = ''; + let lastBoundary = ''; + for (let i = 0; i < chars.length; i += 1) { + const ch = chars[i]; + if (mark.test(ch)) { + out += ch; + continue; + } + if (lastBoundary) { + const hanNow = hanChar.test(ch); + const hanPrev = hanChar.test(lastBoundary); + if ((hanNow && hanPrev) || (hanNow && letterOrNumber.test(lastBoundary)) || (hanPrev && letterOrNumber.test(ch))) { + out += ' '; + } + } + out += ch; + lastBoundary = ch; + } + return out; + }); + const row = nextDb.prepare("SELECT cjk_seg(?) AS v").get('探针'); + if (!row || row.v !== '探 针') { + throw new Error("cjk_seg probe failed: expected '探 针', got " + JSON.stringify(row && row.v)); + } + const hasMessages = !!nextDb.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get('messages'); + const hasFts = !!nextDb.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get('messages_fts'); + const hasRows = !!nextDb.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get('messages_fts_rows'); + if (!hasMessages || !hasFts || !hasRows) return; + nextDb.exec('DROP TRIGGER IF EXISTS temp.messages_fts_insert_cjk;'); + nextDb.exec('DROP TRIGGER IF EXISTS temp.messages_fts_update_cjk;'); + nextDb.exec( + "CREATE TEMP TRIGGER messages_fts_insert_cjk AFTER INSERT ON messages " + + "WHEN new.rewind_at IS NULL AND new.role IN " + whitelist + " BEGIN " + + "INSERT OR IGNORE INTO messages_fts_rows(message_id) VALUES (new.id); " + + "INSERT OR REPLACE INTO messages_fts(rowid, message_id, session_id, role, content) " + + "SELECT fts_rowid, new.id, new.session_id, new.role, cjk_seg(new.content) " + + "FROM messages_fts_rows WHERE message_id = new.id; END;", + ); + nextDb.exec( + "CREATE TEMP TRIGGER messages_fts_update_cjk AFTER UPDATE OF id, session_id, role, content, rewind_at ON messages " + + "WHEN old.id IS NOT new.id OR old.session_id IS NOT new.session_id OR old.role IS NOT new.role " + + "OR old.content IS NOT new.content OR old.rewind_at IS NOT new.rewind_at BEGIN " + + "DELETE FROM messages_fts WHERE rowid = (SELECT fts_rowid FROM messages_fts_rows WHERE message_id = old.id); " + + "UPDATE messages_fts_rows SET message_id = new.id WHERE message_id = old.id AND old.id IS NOT new.id; " + + "INSERT OR IGNORE INTO messages_fts_rows(message_id) SELECT new.id " + + "WHERE new.rewind_at IS NULL AND new.role IN " + whitelist + "; " + + "INSERT OR REPLACE INTO messages_fts(rowid, message_id, session_id, role, content) " + + "SELECT fts_rowid, new.id, new.session_id, new.role, cjk_seg(new.content) FROM messages_fts_rows " + + "WHERE message_id = new.id AND new.rewind_at IS NULL AND new.role IN " + whitelist + "; END;", + ); +} + function hashMigrationFile(filePath) { const raw = fs.readFileSync(filePath, 'utf-8'); const normalized = raw.replace(/\\r\\n/g, '\\n'); @@ -206,6 +268,7 @@ function createDatabase(opts) { const dbOpts = opts && opts.nativeBinding ? { nativeBinding: opts.nativeBinding } : {}; const nextDb = new Database(dbPath, dbOpts); try { + registerCjkSeg(nextDb); applyPragmas(nextDb); if (dbPath !== ':memory:') { const vec = loadSqliteVec(nextDb, opts.sqliteVecExtPath); diff --git a/apps/desktop/src/main/localDb/conversationSearch.pure.ts b/apps/desktop/src/main/localDb/conversationSearch.pure.ts index 987a6bb9ac..2db2557a6a 100644 --- a/apps/desktop/src/main/localDb/conversationSearch.pure.ts +++ b/apps/desktop/src/main/localDb/conversationSearch.pure.ts @@ -227,8 +227,9 @@ function activityMs(session: ConversationSearchSessionSummary): number { export function textPreview(value: unknown, max = 180): string { const text = extractText(value).replace(/\s+/g, ' ').trim(); - if (text.length <= max) return text; - return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}...`; + const chars = [...text]; + if (chars.length <= max) return text; + return `${chars.slice(0, Math.max(0, max - 1)).join('').replace(/\s+$/u, '')}...`; } export interface NormalizedConversationContentPreview { @@ -366,14 +367,23 @@ function keywordRanges(text: string, query: string): Array<{ start: number; end: .sort((a, b) => b.length - a.length); if (tokens.length === 0 || !text) return []; - const lowerText = text.toLocaleLowerCase(); + // 不能先 toLocaleLowerCase() 再用折叠串的下标切原文:大小写折叠会改变长度 + // (如 U+0130「İ」折叠为 i + U+0307),emoji 等多单元字符也会让两套下标失配。 + // 为折叠串的每个 UTF-16 单元记录「折叠偏移 → 原文 UTF-16 偏移」的映射, + // 命中偏移经映射换算回原文坐标后再切,切片恒落在码点边界。 + const { lowerText, foldOffsetToOrig16 } = foldWithOrigMap(text); const ranges: Array<{ start: number; end: number }> = []; for (const token of tokens) { - const lowerToken = token.toLocaleLowerCase(); + const lowerToken = token.toLowerCase(); let index = lowerText.indexOf(lowerToken); while (index >= 0) { - const next = { start: index, end: index + token.length }; - if (!ranges.some((range) => rangesOverlap(range, next))) { + const endUnit = index + lowerToken.length - 1; + if (index >= foldOffsetToOrig16.length || endUnit >= foldOffsetToOrig16.length) break; + const next = { + start: foldOffsetToOrig16[index]!, + end: foldOffsetToOrig16[endUnit]! + codePointLenAt(text, foldOffsetToOrig16[endUnit]!), + }; + if (next.end > next.start && !ranges.some((range) => rangesOverlap(range, next))) { ranges.push(next); } index = lowerText.indexOf(lowerToken, index + lowerToken.length); @@ -382,16 +392,56 @@ function keywordRanges(text: string, query: string): Array<{ start: number; end: return ranges.sort((a, b) => a.start - b.start); } +/** 求原文在 UTF-16 偏移 pos 处的码点长度(1 或 2),用于把起始偏移换算成结束偏移。 */ +function codePointLenAt(text: string, pos: number): number { + return (text.codePointAt(pos) ?? 0) > 0xffff ? 2 : 1; +} + +/** + * 一次折叠同时产出小写串和「折叠 UTF-16 偏移 → 原文 UTF-16 偏移」映射。 + * 不用 toLocaleLowerCase():lt/tr 等 locale 有上下文相关规则,逐码点折叠 + * 与整串折叠可能不等长,后面的 ASCII 会漏匹配。 + */ +function foldWithOrigMap(text: string): { lowerText: string; foldOffsetToOrig16: number[] } { + const foldOffsetToOrig16: number[] = []; + let lowerText = ''; + let orig16 = 0; + for (const ch of text) { + const folded = ch.toLowerCase(); + lowerText += folded; + for (let k = 0; k < folded.length; k += 1) { + foldOffsetToOrig16.push(orig16); + } + orig16 += ch.length; + } + return { lowerText, foldOffsetToOrig16 }; +} + function visibleSnippet(text: string, range: { start: number; end: number }, max: number): string { - if (text.length <= max) return text; - const hitLength = Math.max(1, range.end - range.start); + const chars = [...text]; + if (chars.length <= max) return text; + const hitStartCp = utf16ToCodePointIndex(text, range.start); + const hitEndCp = utf16ToCodePointIndex(text, range.end); + const hitLength = Math.max(1, hitEndCp - hitStartCp); const side = Math.max(0, Math.floor((max - hitLength) / 2)); - let start = Math.max(0, range.start - side); - const end = Math.min(text.length, start + max); + let start = Math.max(0, hitStartCp - side); + let end = Math.min(chars.length, start + max); start = Math.max(0, end - max); const prefix = start > 0 ? '...' : ''; - const suffix = end < text.length ? '...' : ''; - return `${prefix}${text.slice(start, end).trim()}${suffix}`; + const suffix = end < chars.length ? '...' : ''; + return `${prefix}${chars.slice(start, end).join('').trim()}${suffix}`; +} + +/** UTF-16 偏移 → 码点下标;落在低代理上时算到该码点。 */ +function utf16ToCodePointIndex(text: string, utf16: number): number { + let i = 0; + let cp = 0; + while (i < text.length && i < utf16) { + const code = text.charCodeAt(i); + i += code >= 0xd800 && code <= 0xdbff ? 2 : 1; + cp += 1; + } + return cp; } function rangesOverlap(a: { start: number; end: number }, b: { start: number; end: number }): boolean { diff --git a/apps/desktop/src/main/localDb/conversationSearch.ts b/apps/desktop/src/main/localDb/conversationSearch.ts index f3ecba4a0f..8cef8dd506 100644 --- a/apps/desktop/src/main/localDb/conversationSearch.ts +++ b/apps/desktop/src/main/localDb/conversationSearch.ts @@ -19,11 +19,13 @@ import { getDbClient } from './client/current.js'; import { messages, sessions } from './schema.js'; import { searchChatHistoryHybrid } from './chatHistorySearch.js'; import { normalizeDbAgentKind } from '../../shared/agentKindConversion.js'; +import { visibleTextMatchesMessagesFtsQuery } from './chatHistorySearch.pure.js'; import { collectContentHitsUntilUniqueSessions, fuzzyTitleMatch, mergeConversationSearchResults, normalizeConversationContentPreview, + visibleMessageTextForConversationSearch, } from './conversationSearch.pure.js'; const DEFAULT_LIMIT = 20; @@ -128,7 +130,18 @@ export async function searchConversations( if (!messageClientId) continue; const hitContext = hit.context.find((item) => item.isHit) ?? hit.context[0] ?? null; const preview = normalizeConversationContentPreview(hit.role, hitContext?.content ?? '', query); - const ftsRank = preview.keywordMatchedVisibleText ? hit.ftsRank : null; + // 可见文本对 query 字面不匹配时,仍可能是 FTS 命中(「边,界」按字相邻)。 + // 不要只靠 keywordRanges 清掉 ftsRank,否则这类命中会被整条丢弃。 + // 但附件文件名、内部 citation 等隐藏字段命中不能当可见结果: + // preview 非空只说明消息有正文,不能证明 MATCH 落在可见字上。 + const visibleText = visibleMessageTextForConversationSearch( + hit.role, + hitContext?.content ?? '', + ); + const ftsRank = + preview.keywordMatchedVisibleText || visibleTextMatchesMessagesFtsQuery(visibleText, query) + ? hit.ftsRank + : null; if (ftsRank === null && hit.vectorRank === null) continue; contentHits.push({ session, diff --git a/apps/desktop/src/main/localDb/messagesFtsRowsRepair.ts b/apps/desktop/src/main/localDb/messagesFtsRowsRepair.ts index 842491df05..966a1a6d2c 100644 --- a/apps/desktop/src/main/localDb/messagesFtsRowsRepair.ts +++ b/apps/desktop/src/main/localDb/messagesFtsRowsRepair.ts @@ -1,5 +1,11 @@ import type Database from 'better-sqlite3'; +import { + createMessagesFtsPersistentTriggers, + installCjkSegTempTriggers, + registerCjkSeg, +} from './registerCjkSeg'; + const FTS_ROLE_WHITELIST = "('user', 'assistant', 'ask_user', 'plan_review')"; function tableExists(db: Database.Database, name: string): boolean { @@ -47,7 +53,7 @@ function rebuildFts(db: Database.Database): void { `); db.exec(` INSERT INTO messages_fts(rowid, message_id, session_id, role, content) - SELECT r.fts_rowid, m.id, m.session_id, m.role, m.content + SELECT r.fts_rowid, m.id, m.session_id, m.role, cjk_seg(m.content) FROM messages m JOIN messages_fts_rows r ON r.message_id = m.id; `); @@ -88,7 +94,7 @@ function reuseExistingFtsRows(db: Database.Database): boolean { WHERE f.message_id IS NOT m.id OR f.session_id IS NOT m.session_id OR f.role IS NOT m.role - OR f.content IS NOT m.content + OR f.content IS NOT cjk_seg(m.content) LIMIT 1`, ) .get(); @@ -108,73 +114,20 @@ function reuseExistingFtsRows(db: Database.Database): boolean { return !missingFtsRow; } -function createTriggers(db: Database.Database): void { - db.exec(` - CREATE TRIGGER messages_fts_insert - AFTER INSERT ON messages - WHEN new.rewind_at IS NULL AND new.role IN ${FTS_ROLE_WHITELIST} - BEGIN - INSERT OR IGNORE INTO messages_fts_rows(message_id) VALUES (new.id); - INSERT OR REPLACE INTO messages_fts(rowid, message_id, session_id, role, content) - SELECT fts_rowid, new.id, new.session_id, new.role, new.content - FROM messages_fts_rows - WHERE message_id = new.id; - END; - `); - - db.exec(` - CREATE TRIGGER messages_fts_delete - AFTER DELETE ON messages - BEGIN - DELETE FROM messages_fts - WHERE rowid = ( - SELECT fts_rowid FROM messages_fts_rows WHERE message_id = old.id - ); - DELETE FROM messages_fts_rows WHERE message_id = old.id; - END; - `); - - db.exec(` - CREATE TRIGGER messages_fts_update - AFTER UPDATE OF id, session_id, role, content, rewind_at ON messages - WHEN old.id IS NOT new.id - OR old.session_id IS NOT new.session_id - OR old.role IS NOT new.role - OR old.content IS NOT new.content - OR old.rewind_at IS NOT new.rewind_at - BEGIN - DELETE FROM messages_fts - WHERE rowid = ( - SELECT fts_rowid FROM messages_fts_rows WHERE message_id = old.id - ); - UPDATE messages_fts_rows - SET message_id = new.id - WHERE message_id = old.id AND old.id IS NOT new.id; - INSERT OR IGNORE INTO messages_fts_rows(message_id) - SELECT new.id - WHERE new.rewind_at IS NULL AND new.role IN ${FTS_ROLE_WHITELIST}; - INSERT OR REPLACE INTO messages_fts(rowid, message_id, session_id, role, content) - SELECT fts_rowid, new.id, new.session_id, new.role, new.content - FROM messages_fts_rows - WHERE message_id = new.id - AND new.rewind_at IS NULL - AND new.role IN ${FTS_ROLE_WHITELIST}; - END; - `); -} - /** Restores the data-bearing rowid map together with the FTS state that depends on it. */ export function repairMessagesFtsRows(db: Database.Database): void { + registerCjkSeg(db); const run = db.transaction(() => { - db.exec('DROP TRIGGER IF EXISTS messages_fts_insert;'); - db.exec('DROP TRIGGER IF EXISTS messages_fts_delete;'); - db.exec('DROP TRIGGER IF EXISTS messages_fts_update;'); + db.exec('DROP TRIGGER IF EXISTS main.messages_fts_insert;'); + db.exec('DROP TRIGGER IF EXISTS main.messages_fts_delete;'); + db.exec('DROP TRIGGER IF EXISTS main.messages_fts_update;'); createRowsTable(db); if (!tableExists(db, 'messages')) return; if (!tableExists(db, 'messages_fts') || !reuseExistingFtsRows(db)) rebuildFts(db); - createTriggers(db); + createMessagesFtsPersistentTriggers(db); + installCjkSegTempTriggers(db); }); run(); } diff --git a/apps/desktop/src/main/localDb/registerCjkSeg.ts b/apps/desktop/src/main/localDb/registerCjkSeg.ts new file mode 100644 index 0000000000..d5ae345c47 --- /dev/null +++ b/apps/desktop/src/main/localDb/registerCjkSeg.ts @@ -0,0 +1,140 @@ +import type Database from 'better-sqlite3'; + +import { CJK_SEG_SQL_FN, cjkSeg } from './cjkSeg'; + +const FTS_ROLE_WHITELIST = "('user', 'assistant', 'ask_user', 'plan_review')"; +const CJK_SEG_FUNCTION_GUARD = `NOT EXISTS (SELECT 1 FROM pragma_function_list WHERE name='${CJK_SEG_SQL_FN}')`; + +/** + * 每个新客户端写 `messages` 的连接都必须先注册 `cjk_seg`,再挂 TEMP 触发器。 + * 持久触发器只写原文、且 WHEN 看到已注册函数时跳过,所以旧客户端漏注册仍能插入, + * 只是新行不再按字切。注册必须幂等,重复打开连接不能抛。 + */ +export function registerCjkSeg(db: Database.Database): void { + db.function(CJK_SEG_SQL_FN, { deterministic: true }, (value: unknown) => cjkSeg(value)); + installCjkSegTempTriggers(db); +} + +export function assertCjkSegRegistered(db: Database.Database): void { + const row = db.prepare(`SELECT ${CJK_SEG_SQL_FN}(?) AS v`).get('探针') as { v: string }; + if (row?.v !== '探 针') { + throw new Error(`${CJK_SEG_SQL_FN} probe failed: expected '探 针', got ${JSON.stringify(row?.v)}`); + } +} + +function tableExists(db: Database.Database, name: string): boolean { + return !!db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(name); +} + +/** + * 连接级 TEMP 触发器:新客户端按字写入 FTS。同名 TEMP 不会覆盖持久触发器, + * 两者都会 fire,所以持久 insert/update 必须用 `cjk_seg` 函数存在性守卫跳过。 + * TEMP 对象不进库文件,旧连接看不到它们。 + */ +export function installCjkSegTempTriggers(db: Database.Database): void { + if (!tableExists(db, 'messages') || !tableExists(db, 'messages_fts') || !tableExists(db, 'messages_fts_rows')) { + return; + } + + db.exec('DROP TRIGGER IF EXISTS temp.messages_fts_insert_cjk;'); + db.exec('DROP TRIGGER IF EXISTS temp.messages_fts_update_cjk;'); + + db.exec(` + CREATE TEMP TRIGGER messages_fts_insert_cjk + AFTER INSERT ON messages + WHEN new.rewind_at IS NULL AND new.role IN ${FTS_ROLE_WHITELIST} + BEGIN + INSERT OR IGNORE INTO messages_fts_rows(message_id) VALUES (new.id); + INSERT OR REPLACE INTO messages_fts(rowid, message_id, session_id, role, content) + SELECT fts_rowid, new.id, new.session_id, new.role, ${CJK_SEG_SQL_FN}(new.content) + FROM messages_fts_rows + WHERE message_id = new.id; + END; + `); + + db.exec(` + CREATE TEMP TRIGGER messages_fts_update_cjk + AFTER UPDATE OF id, session_id, role, content, rewind_at ON messages + WHEN old.id IS NOT new.id + OR old.session_id IS NOT new.session_id + OR old.role IS NOT new.role + OR old.content IS NOT new.content + OR old.rewind_at IS NOT new.rewind_at + BEGIN + DELETE FROM messages_fts + WHERE rowid = ( + SELECT fts_rowid FROM messages_fts_rows WHERE message_id = old.id + ); + UPDATE messages_fts_rows + SET message_id = new.id + WHERE message_id = old.id AND old.id IS NOT new.id; + INSERT OR IGNORE INTO messages_fts_rows(message_id) + SELECT new.id + WHERE new.rewind_at IS NULL AND new.role IN ${FTS_ROLE_WHITELIST}; + INSERT OR REPLACE INTO messages_fts(rowid, message_id, session_id, role, content) + SELECT fts_rowid, new.id, new.session_id, new.role, ${CJK_SEG_SQL_FN}(new.content) + FROM messages_fts_rows + WHERE message_id = new.id + AND new.rewind_at IS NULL + AND new.role IN ${FTS_ROLE_WHITELIST}; + END; + `); +} + +/** 持久触发器写原文;已注册 `cjk_seg` 的连接由 TEMP 触发器接管 insert/update。 */ +export function createMessagesFtsPersistentTriggers(db: Database.Database): void { + db.exec(` + CREATE TRIGGER messages_fts_insert + AFTER INSERT ON messages + WHEN new.rewind_at IS NULL AND new.role IN ${FTS_ROLE_WHITELIST} + AND ${CJK_SEG_FUNCTION_GUARD} + BEGIN + INSERT OR IGNORE INTO messages_fts_rows(message_id) VALUES (new.id); + INSERT OR REPLACE INTO messages_fts(rowid, message_id, session_id, role, content) + SELECT fts_rowid, new.id, new.session_id, new.role, new.content + FROM messages_fts_rows + WHERE message_id = new.id; + END; + `); + + db.exec(` + CREATE TRIGGER messages_fts_delete + AFTER DELETE ON messages + BEGIN + DELETE FROM messages_fts + WHERE rowid = ( + SELECT fts_rowid FROM messages_fts_rows WHERE message_id = old.id + ); + DELETE FROM messages_fts_rows WHERE message_id = old.id; + END; + `); + + db.exec(` + CREATE TRIGGER messages_fts_update + AFTER UPDATE OF id, session_id, role, content, rewind_at ON messages + WHEN (old.id IS NOT new.id + OR old.session_id IS NOT new.session_id + OR old.role IS NOT new.role + OR old.content IS NOT new.content + OR old.rewind_at IS NOT new.rewind_at) + AND ${CJK_SEG_FUNCTION_GUARD} + BEGIN + DELETE FROM messages_fts + WHERE rowid = ( + SELECT fts_rowid FROM messages_fts_rows WHERE message_id = old.id + ); + UPDATE messages_fts_rows + SET message_id = new.id + WHERE message_id = old.id AND old.id IS NOT new.id; + INSERT OR IGNORE INTO messages_fts_rows(message_id) + SELECT new.id + WHERE new.rewind_at IS NULL AND new.role IN ${FTS_ROLE_WHITELIST}; + INSERT OR REPLACE INTO messages_fts(rowid, message_id, session_id, role, content) + SELECT fts_rowid, new.id, new.session_id, new.role, new.content + FROM messages_fts_rows + WHERE message_id = new.id + AND new.rewind_at IS NULL + AND new.role IN ${FTS_ROLE_WHITELIST}; + END; + `); +} diff --git a/apps/desktop/src/main/localDb/worker/__tests__/runtime.test.ts b/apps/desktop/src/main/localDb/worker/__tests__/runtime.test.ts index df574c01a8..bdd222b505 100644 --- a/apps/desktop/src/main/localDb/worker/__tests__/runtime.test.ts +++ b/apps/desktop/src/main/localDb/worker/__tests__/runtime.test.ts @@ -65,6 +65,7 @@ describe('db worker runtime', () => { expect(findLogPayload(logs, 'dbWorker.schemaDrift')).toMatchObject({ status: 'unknown', }); + expect(db.prepare('SELECT cjk_seg(?) AS v').get('探针')).toEqual({ v: '探 针' }); } finally { db.close(); } diff --git a/apps/desktop/src/main/localDb/worker/runtime.ts b/apps/desktop/src/main/localDb/worker/runtime.ts index a48c765a7f..23d3119164 100644 --- a/apps/desktop/src/main/localDb/worker/runtime.ts +++ b/apps/desktop/src/main/localDb/worker/runtime.ts @@ -9,6 +9,7 @@ import { readSchemaVersion, } from '../migrationRunner.js'; import { detectSchemaDriftInDir } from '../schemaDriftCore.js'; +import { assertCjkSegRegistered, registerCjkSeg } from '../registerCjkSeg.js'; export interface DbWorkerRuntimeOptions { userId?: string; @@ -39,6 +40,8 @@ export function createWorkerDatabase( const dbOpts = opts.nativeBinding ? { nativeBinding: opts.nativeBinding } : {}; const db = new DatabaseCtor(dbPath, dbOpts); try { + registerCjkSeg(db); + assertCjkSegRegistered(db); applyPragmas(db); if (dbPath !== ':memory:') { diff --git a/apps/desktop/src/main/maker-host/__tests__/codexProxyHost.test.ts b/apps/desktop/src/main/maker-host/__tests__/codexProxyHost.test.ts index 46b72f1d02..4dfcc2de49 100644 --- a/apps/desktop/src/main/maker-host/__tests__/codexProxyHost.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/codexProxyHost.test.ts @@ -11,6 +11,12 @@ type Registry = { readonly size: number; }; +// 每个用例都经 freshCodexProxyHost() 的 vi.resetModules() + 动态 import 重新加载 +// 整条 SUT 模块链(maker-core / compat-proxy / bridges)。首个用例还要承担全部 +// 依赖的首次编译加载,满载 CI 上会超出 vitest 默认 5s。只放宽本文件的预算, +// 断言与流程不变。 +vi.setConfig({ testTimeout: 20_000 }); + const mockState = vi.hoisted(() => { let capturedRegistry: Registry | null = null; const state = { diff --git a/apps/desktop/src/main/maker-host/__tests__/session-search.test.ts b/apps/desktop/src/main/maker-host/__tests__/session-search.test.ts new file mode 100644 index 0000000000..8714535f5e --- /dev/null +++ b/apps/desktop/src/main/maker-host/__tests__/session-search.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const dbMocks = vi.hoisted(() => ({ + query: vi.fn(), +})); + +vi.mock('../../localDb/client/current.js', () => ({ + getDbClient: () => ({ + query: dbMocks.query, + }), +})); + +vi.mock('../../logger.js', () => ({ + createLogger: () => ({ + warn: vi.fn(), + }), +})); + +import { searchSessionsFn } from '../session-search'; + +describe('searchSessionsFn', () => { + beforeEach(() => { + dbMocks.query.mockReset(); + dbMocks.query.mockResolvedValue([]); + }); + + it('CJK 短词收成相邻 phrase,多词 AND', async () => { + await searchSessionsFn('登录 报错'); + expect(dbMocks.query).toHaveBeenCalledTimes(1); + expect(dbMocks.query.mock.calls[0][1][0]).toBe(16_384); + expect(dbMocks.query.mock.calls[0][1][1]).toBe('"登 录" AND "报 错"'); + }); + + it('英文多词保持 AND,不退化成 OR', async () => { + await searchSessionsFn('login bug'); + expect(dbMocks.query.mock.calls[0][1][1]).toBe('"login" AND "bug"'); + }); + + it('纯标点返回空且不查库', async () => { + await expect(searchSessionsFn('***')).resolves.toEqual([]); + expect(dbMocks.query).not.toHaveBeenCalled(); + }); + + it('snippet 从原文重建并透传过滤参数(不用索引侧文本)', async () => { + dbMocks.query.mockResolvedValue([ + { + messageId: 'm1', + sessionId: 's1', + role: 'user', + ts: 1, + content: '登录 报错了', + score: 0.1, + }, + ]); + const hits = await searchSessionsFn('登录', { sessionId: 's1', role: 'user', limit: 3 }); + // 原文真实空格保留、只高亮命中词;不再走 SQLite snippet()。 + expect(hits).toEqual([ + { + messageId: 'm1', + sessionId: 's1', + role: 'user', + ts: 1, + snippet: '登录 报错了', + score: 0.1, + }, + ]); + const [sql, params] = dbMocks.query.mock.calls[0]; + expect(sql).toContain('MATCH ?'); + expect(sql).toContain('substr(m.content, 1, ?)'); + expect(sql).not.toContain('snippet('); + expect(sql).not.toContain('offsets('); + expect(sql).toContain('m.session_id = ?'); + expect(sql).toContain('m.role = ?'); + expect(params).toEqual([16_384, '"登 录"', 's1', 'user', 3]); + }); + + it('中英相邻与多命中区间在 snippet 中不产生假空格且高亮正确', async () => { + dbMocks.query.mockResolvedValue([ + { + messageId: 'm2', + sessionId: 's1', + role: 'user', + ts: 2, + content: 'foo登录bar后 login 失败', + score: 0.2, + }, + ]); + const hits = await searchSessionsFn('foo登录bar login'); + // 原文无空格处不得出现假空格;原文真实空格原样保留。 + expect(hits[0]!.snippet).toBe('foo登录barlogin 失败'); + }); +}); diff --git a/apps/desktop/src/main/maker-host/session-search.ts b/apps/desktop/src/main/maker-host/session-search.ts index 14eb18df1b..92ba149a62 100644 --- a/apps/desktop/src/main/maker-host/session-search.ts +++ b/apps/desktop/src/main/maker-host/session-search.ts @@ -19,11 +19,15 @@ import type { SessionSearchOptions, SessionSearchHit, SessionSearchFn } from '@cindy/mcps'; import { getDbClient } from '../localDb/client/current.js'; +import { + buildMessagesFtsMatch, + extractMessagesFtsTokens, +} from '../localDb/chatHistorySearch.pure.js'; +import { buildSnippetFromContent, SNIPPET_SOURCE_MAX_CHARS } from '../localDb/cjkSeg.js'; import { createLogger } from '../logger.js'; const log = createLogger('session-search'); const TABLE = 'messages_fts'; -const SNIPPET_TOKEN_RADIUS = 8; const DEFAULT_LIMIT = 10; export const searchSessionsFn: SessionSearchFn = async ( @@ -32,7 +36,8 @@ export const searchSessionsFn: SessionSearchFn = async ( ): Promise => { if (!query || query.trim().length === 0) return []; const limit = Math.max(1, Math.min(opts.limit ?? DEFAULT_LIMIT, 50)); - const escapedQuery = `"${query.trim().replace(/"/g, '""')}"`; + const escapedQuery = buildMessagesFtsMatch(query, 'AND'); + if (!escapedQuery) return []; try { getDbClient(); @@ -41,20 +46,21 @@ export const searchSessionsFn: SessionSearchFn = async ( throw new Error('DbClient not ready'); } - // messages_fts 表自带 message_id / session_id / role / content; ts 走 messages 表 join + // snippet 从原文 m.content 重建,不用索引侧文本(见 buildSnippetFromContent 注释)。 + const queryTokens = extractMessagesFtsTokens(query); let sql = ` SELECT m.id AS messageId, m.session_id AS sessionId, m.role AS role, m.created_at AS ts, - snippet(${TABLE}, -1, '', '', '…', ${SNIPPET_TOKEN_RADIUS}) AS snippet, + substr(m.content, 1, ?) AS content, bm25(${TABLE}) AS score FROM ${TABLE} JOIN messages m ON m.id = ${TABLE}.message_id WHERE ${TABLE} MATCH ? AND m.rewind_at IS NULL `; - const params: unknown[] = [escapedQuery]; + const params: unknown[] = [SNIPPET_SOURCE_MAX_CHARS, escapedQuery]; if (opts.sessionId) { sql += ` AND m.session_id = ?`; params.push(opts.sessionId); @@ -72,7 +78,7 @@ export const searchSessionsFn: SessionSearchFn = async ( sessionId: string; role: string; ts: number; - snippet: string; + content: string; score: number; }>(sql, params); return rows.map((r) => ({ @@ -80,7 +86,7 @@ export const searchSessionsFn: SessionSearchFn = async ( sessionId: r.sessionId, role: r.role, ts: r.ts, - snippet: r.snippet, + snippet: buildSnippetFromContent(r.content, queryTokens), score: r.score, })); } catch (e) { diff --git a/apps/desktop/src/main/windowsPackagedInstanceBarrier.ts b/apps/desktop/src/main/windowsPackagedInstanceBarrier.ts index 5a36b02ce2..291b528412 100644 --- a/apps/desktop/src/main/windowsPackagedInstanceBarrier.ts +++ b/apps/desktop/src/main/windowsPackagedInstanceBarrier.ts @@ -1,12 +1,33 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; const DEFAULT_ACQUIRE_TIMEOUT_MS = 5_000; +const HELPER_START_TIMEOUT_MS = 5_000; +const HELPER_PROBE_TIMEOUT_MS = 5_000; const HELPER_EXIT_TIMEOUT_MS = 2_000; const MAX_HELPER_OUTPUT_BYTES = 16 * 1024; const WINDOWS_PACKAGED_INSTANCE_BARRIER_SCRIPT = String.raw` $ErrorActionPreference = 'Stop' -Add-Type -TypeDefinition @' +[Console]::Out.WriteLine('{"status":"started"}') +[Console]::Out.Flush() + +$mutex = [System.Threading.Mutex]::new($false, $env:CINDY_SINGLETON_MUTEX_NAME) +$acquired = $false +try { + try { + $acquired = $mutex.WaitOne([int]$env:CINDY_SINGLETON_WAIT_MS) + } catch [System.Threading.AbandonedMutexException] { + $acquired = $true + } + if (-not $acquired) { + [Console]::Out.WriteLine('{"status":"busy"}') + [Console]::Out.Flush() + exit 2 + } + + [Console]::Out.WriteLine('{"status":"locked"}') + [Console]::Out.Flush() + Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; @@ -24,20 +45,6 @@ public static class CindyProcessSingletonProbe { } '@ -$mutex = [System.Threading.Mutex]::new($false, $env:CINDY_SINGLETON_MUTEX_NAME) -$acquired = $false -try { - try { - $acquired = $mutex.WaitOne([int]$env:CINDY_SINGLETON_WAIT_MS) - } catch [System.Threading.AbandonedMutexException] { - $acquired = $true - } - if (-not $acquired) { - [Console]::Out.WriteLine('{"status":"busy"}') - [Console]::Out.Flush() - exit 2 - } - $messageOnlyWindow = [IntPtr]::new(-3) $window = [CindyProcessSingletonProbe]::FindWindowEx( $messageOnlyWindow, @@ -67,7 +74,11 @@ try { `; type BarrierStatus = - { status: 'acquired' } | { status: 'busy' } | { status: 'occupied'; pid: number }; + | { status: 'started' } + | { status: 'locked' } + | { status: 'acquired' } + | { status: 'busy' } + | { status: 'occupied'; pid: number }; function processSingletonNames(programName: string): { mutexName: string; @@ -93,7 +104,7 @@ function powershellPath(): string { return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; } -function waitForFirstLine( +function waitForBarrierStatus( child: ChildProcessWithoutNullStreams, timeoutMs: number, ): Promise<{ line: string; stderr: string }> { @@ -101,6 +112,7 @@ function waitForFirstLine( let stdout = ''; let stderr = ''; let settled = false; + let timer: ReturnType | undefined; const finish = (settle: () => void): void => { if (settled) return; settled = true; @@ -111,10 +123,17 @@ function waitForFirstLine( child.off('exit', onExit); settle(); }; - const timer = setTimeout(() => { - finish(() => reject(new Error('timed out acquiring Windows packaged-instance barrier'))); - child.kill(); - }, timeoutMs + 1_000); + // timeoutMs 只度量 mutex 竞争;PowerShell 冷启动与 Add-Type 编译是独立阶段, + // 满载 CI 上共预算会把健康的 acquire 误杀成超时。 + const armTimeout = (durationMs: number, stage: string): void => { + clearTimeout(timer); + timer = setTimeout(() => { + finish(() => + reject(new Error(`timed out ${stage} Windows packaged-instance barrier`)), + ); + child.kill(); + }, durationMs); + }; const onStdout = (chunk: Buffer | string): void => { stdout += chunk.toString(); if (stdout.length > MAX_HELPER_OUTPUT_BYTES) { @@ -124,10 +143,28 @@ function waitForFirstLine( child.kill(); return; } - const newline = stdout.indexOf('\n'); - if (newline >= 0) { + let newline = stdout.indexOf('\n'); + while (newline >= 0) { const line = stdout.slice(0, newline).trim(); - finish(() => resolve({ line, stderr })); + stdout = stdout.slice(newline + 1); + let status: BarrierStatus; + try { + status = parseBarrierStatus(line); + } catch (error) { + finish(() => reject(error)); + child.kill(); + return; + } + if (status.status === 'started') { + armTimeout(timeoutMs + 1_000, 'waiting for'); + } else if (status.status === 'locked') { + // Add-Type + message-window 探测只在 mutex 安全持有后发生。 + armTimeout(HELPER_PROBE_TIMEOUT_MS, 'probing'); + } else { + finish(() => resolve({ line, stderr })); + return; + } + newline = stdout.indexOf('\n'); } }; const onStderr = (chunk: Buffer | string): void => { @@ -149,12 +186,20 @@ function waitForFirstLine( child.stderr.on('data', onStderr); child.once('error', onError); child.once('exit', onExit); + armTimeout(HELPER_START_TIMEOUT_MS, 'starting'); }); } function parseBarrierStatus(line: string): BarrierStatus { const value = JSON.parse(line) as Partial; - if (value.status === 'acquired' || value.status === 'busy') return { status: value.status }; + if ( + value.status === 'started' || + value.status === 'locked' || + value.status === 'acquired' || + value.status === 'busy' + ) { + return { status: value.status }; + } if ( value.status === 'occupied' && typeof value.pid === 'number' && @@ -166,21 +211,31 @@ function parseBarrierStatus(line: string): BarrierStatus { throw new Error('Windows packaged-instance barrier returned an invalid status'); } -function waitForExit(child: ChildProcessWithoutNullStreams): Promise { +function waitForExit( + child: ChildProcessWithoutNullStreams, + timeoutMs = HELPER_EXIT_TIMEOUT_MS, +): Promise { if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); return new Promise((resolve) => { let settled = false; const finish = (): void => { if (settled) return; settled = true; - clearTimeout(timer); + clearTimeout(terminateTimer); + clearTimeout(forceFinishTimer); child.off('exit', finish); resolve(); }; - const timer = setTimeout(() => { + let forceFinishTimer: ReturnType | undefined; + const terminateTimer = setTimeout(() => { child.kill(); - finish(); - }, HELPER_EXIT_TIMEOUT_MS); + // Windows 上 TerminateProcess 是异步的。继续等真实 exit 事件, + // 避免重试与仍持有 mutex 的进程竞态;坏主机再留第二段兜底。 + forceFinishTimer = setTimeout(() => { + child.kill(); + finish(); + }, timeoutMs); + }, timeoutMs); child.once('exit', finish); }); } @@ -222,7 +277,7 @@ export async function acquireWindowsPackagedInstanceBarrier(options: { ); let status: BarrierStatus; try { - const { line } = await waitForFirstLine(child, timeoutMs); + const { line } = await waitForBarrierStatus(child, timeoutMs); status = parseBarrierStatus(line); } catch (error) { child.kill(); @@ -264,4 +319,5 @@ export const __testing = { WINDOWS_PACKAGED_INSTANCE_BARRIER_SCRIPT, parseBarrierStatus, processSingletonNames, + waitForExit, }; diff --git a/apps/desktop/src/renderer/__tests__/highlightSegments.test.ts b/apps/desktop/src/renderer/__tests__/highlightSegments.test.ts index 9f7c4a30a6..8ec3ffece0 100644 --- a/apps/desktop/src/renderer/__tests__/highlightSegments.test.ts +++ b/apps/desktop/src/renderer/__tests__/highlightSegments.test.ts @@ -69,11 +69,10 @@ describe('highlightSegments — base', () => { it('marks the right characters', () => { const segs = flatten(highlightSegments('hello world', [6, 7])); - // 'hello ' plain, 'w' mark, 'o' mark, 'rld' plain + // 'hello ' plain, 'wo' mark(相邻命中合并为一个 span), 'rld' plain expect(segs).toEqual([ { kind: 'plain', text: 'hello ' }, - { kind: 'mark', text: 'w' }, - { kind: 'mark', text: 'o' }, + { kind: 'mark', text: 'wo' }, { kind: 'plain', text: 'rld' }, ]); }); @@ -100,11 +99,9 @@ describe('highlightSegments — base', () => { }); it('handles every-character highlight', () => { - const segs = flatten(highlightSegments('abc', [0, 1, 2])); - expect(segs).toEqual([ - { kind: 'mark', text: 'a' }, - { kind: 'mark', text: 'b' }, - { kind: 'mark', text: 'c' }, + // 全命中合并成一个 mark span,渲染等价。 + expect(flatten(highlightSegments('abc', [0, 1, 2]))).toEqual([ + { kind: 'mark', text: 'abc' }, ]); }); }); @@ -119,12 +116,11 @@ describe('highlightSegments — defensive', () => { }); it('skips out-of-order indices (smaller-than-cursor)', () => { - // [2, 1, 3] — 1 应被 skip(因为 cursor 已到 3) + // [2, 1, 3] — 1 与已合并的 [2,3) 相邻,归并为一个 span 覆盖 b、c、d。 const segs = flatten(highlightSegments('abcd', [2, 1, 3])); expect(segs).toEqual([ - { kind: 'plain', text: 'ab' }, - { kind: 'mark', text: 'c' }, - { kind: 'mark', text: 'd' }, + { kind: 'plain', text: 'a' }, + { kind: 'mark', text: 'bcd' }, ]); }); @@ -135,4 +131,30 @@ describe('highlightSegments — defensive', () => { expect(markEl).toBeDefined(); expect(isValidElement(markEl) && (markEl.props as { className: string }).className).toBe('custom-x'); }); + + it('keeps surrogate pairs intact when an index lands on a low surrogate', () => { + // emoji 命中(fuzzyTitleMatch 会给出低代理位下标): + // 旧实现把代理对拆成两个 half-surrogate mark(乱码);现在合并回完整码点。 + const segs = flatten(highlightSegments('a😀b', [1, 2])); + expect(segs).toEqual([ + { kind: 'plain', text: 'a' }, + { kind: 'mark', text: '😀' }, + { kind: 'plain', text: 'b' }, + ]); + }); + + it('extends an index on the high surrogate to the full pair', () => { + const segs = flatten(highlightSegments('a😀b', [1])); + expect(segs).toEqual([ + { kind: 'plain', text: 'a' }, + { kind: 'mark', text: '😀' }, + { kind: 'plain', text: 'b' }, + ]); + }); + + it('reconstructs titles containing astral characters exactly', () => { + const title = 'a😀b𠀀c'; + const out = highlightSegments(title, [1, 2, 4, 5]); + expect(reconstruct(out)).toBe(title); + }); }); diff --git a/apps/desktop/src/renderer/features/cc-agent/lib/highlightSegments.tsx b/apps/desktop/src/renderer/features/cc-agent/lib/highlightSegments.tsx index 1c4089d84a..7acec8a416 100644 --- a/apps/desktop/src/renderer/features/cc-agent/lib/highlightSegments.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/lib/highlightSegments.tsx @@ -47,19 +47,39 @@ export function highlightSegments( if (indices.length === 0) return title; const cls = options?.highlightClassName ?? DEFAULT_HIGHLIGHT_CLASS; + // indices 是 UTF-16 code unit 下标;命中 emoji 等多单元字符时可能落在 + // surrogate pair 中间。把每个下标扩成完整码点区间再合并,保证切片 + // 永远落在码点边界,不会把一个字符拆成两个乱码 half-surrogate。 + const spans: Array<{ start: number; end: number }> = []; + for (const i of indices) { + // 防御:跳过越界 / 负数 index + if (i < 0 || i >= title.length) continue; + let start = i; + let end = i + 1; + // 落在低代理上时回退到高代理;落在高代理上时扩到低代理末尾。 + const code = title.charCodeAt(i); + if (code >= 0xdc00 && code <= 0xdfff) start -= 1; + else if (code >= 0xd800 && code <= 0xdbff) end += 1; + const prev = spans[spans.length - 1]; + if (prev && start <= prev.end) { + prev.start = Math.min(prev.start, start); + prev.end = Math.max(prev.end, end); + } else { + spans.push({ start, end }); + } + } + const out: ReactNode[] = []; let cursor = 0; - for (let k = 0; k < indices.length; k += 1) { - const i = indices[k]; - // 防御:跳过越界 / 与 cursor 颠倒(乱序)的 index - if (i < cursor || i >= title.length) continue; - if (i > cursor) out.push(title.slice(cursor, i)); + for (let k = 0; k < spans.length; k += 1) { + const span = spans[k]!; + if (span.start > cursor) out.push(title.slice(cursor, span.start)); out.push( - - {title[i]} + + {title.slice(span.start, span.end)} , ); - cursor = i + 1; + cursor = span.end; } if (cursor < title.length) out.push(title.slice(cursor)); return out; diff --git a/apps/desktop/src/renderer/features/cc-agent/sidebar/ConversationSearchBox.tsx b/apps/desktop/src/renderer/features/cc-agent/sidebar/ConversationSearchBox.tsx index c1921094e4..7156c6fddc 100644 --- a/apps/desktop/src/renderer/features/cc-agent/sidebar/ConversationSearchBox.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/sidebar/ConversationSearchBox.tsx @@ -46,6 +46,7 @@ import { type MachineSelection, } from '@/features/device-link/selectedMachineStore'; import { formatSidebarTime } from '../lib/formatSidebarTime'; +import { parseSnippetMarkup } from './snippetMarkup'; import { getSearchSortBy, setSearchSortBy, @@ -61,6 +62,7 @@ import type { } from '../../../../shared/conversationSearch'; import { conversationSearchTitle } from '../../../../shared/conversationSearch'; import { highlightSegments } from '../lib/highlightSegments'; +import { keywordRanges } from './keywordRanges'; import { resolveSessionRoute } from '@/lib/orcaSessionIdentity'; import { remoteProjectsStore } from '@/features/device-link/remoteProjectsStore'; import { conversationSearchResultKey } from '@/lib/conversationSearchFanout'; @@ -1318,36 +1320,23 @@ function searchSourceLabel( } function renderSnippet(snippet: string | null | undefined, query: string): React.ReactNode | null { - const text = snippet?.trim(); - if (!text) return null; - const parts = text.split(/(<\/?mark>)/g); - const out: React.ReactNode[] = []; - let marked = false; - parts.forEach((part, index) => { - if (!part) return; - if (part === '') { - marked = true; - return; - } - if (part === '') { - marked = false; - return; - } - if (marked) { - out.push( - - {part} - , - ); - } else { - out.push( - - {renderKeywordHighlights(part, query)} - , + // 侧栏 snippet 是原文切片,不是 buildSnippetFromContent 哨兵协议。不要传 protocol:true。 + const parts = parseSnippetMarkup(snippet); + if (!parts) return null; + return parts.map((part, index) => { + if (part.marked) { + return ( + + {part.text} + ); } + return ( + + {renderKeywordHighlights(part.text, query)} + + ); }); - return out.length > 0 ? out : null; } function renderKeywordHighlights(text: string, query: string): React.ReactNode { @@ -1369,33 +1358,6 @@ function renderKeywordHighlights(text: string, query: string): React.ReactNode { return out; } -function keywordRanges(text: string, query: string): Array<{ start: number; end: number }> { - const tokens = [...new Set(query.match(/[\p{L}\p{N}]+/gu) ?? [])] - .map((token) => token.trim()) - .filter((token) => token.length > 0) - .sort((a, b) => b.length - a.length); - if (tokens.length === 0) return []; - - const lowerText = text.toLocaleLowerCase(); - const ranges: Array<{ start: number; end: number }> = []; - for (const token of tokens) { - const lowerToken = token.toLocaleLowerCase(); - let index = lowerText.indexOf(lowerToken); - while (index >= 0) { - const next = { start: index, end: index + token.length }; - if (!ranges.some((range) => rangesOverlap(range, next))) { - ranges.push(next); - } - index = lowerText.indexOf(lowerToken, index + lowerToken.length); - } - } - return ranges.sort((a, b) => a.start - b.start); -} - -function rangesOverlap(a: { start: number; end: number }, b: { start: number; end: number }): boolean { - return a.start < b.end && b.start < a.end; -} - const SEARCH_MARK_CLASS = 'rounded-[2px] bg-search-match-bg px-0.5 font-medium text-search-match-fg'; diff --git a/apps/desktop/src/renderer/features/cc-agent/sidebar/__tests__/keywordRanges.test.ts b/apps/desktop/src/renderer/features/cc-agent/sidebar/__tests__/keywordRanges.test.ts new file mode 100644 index 0000000000..e8c80be132 --- /dev/null +++ b/apps/desktop/src/renderer/features/cc-agent/sidebar/__tests__/keywordRanges.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { keywordRanges } from '../keywordRanges'; + +describe('keywordRanges', () => { + it('ASCII 大小写折叠照常高亮', () => { + expect(keywordRanges('please Login now', 'login')).toEqual([{ start: 7, end: 12 }]); + }); + + it('CJK 照常高亮', () => { + expect(keywordRanges('请先登录再提交', '登录')).toEqual([{ start: 2, end: 4 }]); + }); + + it('İ(U+0130)折叠膨胀时高亮不错位(UTF-16 下标映射回原文)', () => { + // İ 折叠为 i + U+0307(两个码点),旧实现会把 lowerText 的下标直接切原文: + // 搜 bc 会错误切出 İbc;搜 a 会切出空标。 + expect(keywordRanges('İbc', 'bc')).toEqual([{ start: 1, end: 3 }]); + expect(keywordRanges('İa', 'a')).toEqual([{ start: 1, end: 2 }]); + expect(keywordRanges('İ', 'İ')).toEqual([{ start: 0, end: 1 }]); + }); + + it('emoji 等多 UTF-16 单元字符不错位', () => { + // 😀 占 2 个 UTF-16 单元(下标 0–1),「登录」的正确区间是 [2,4)。 + expect(keywordRanges('😀登录报错', '登录')).toEqual([{ start: 2, end: 4 }]); + expect(keywordRanges('😀😀甲乙', '甲乙')).toEqual([{ start: 4, end: 6 }]); + }); + + it('多命中与去重', () => { + expect(keywordRanges('a a a', 'a')).toEqual([ + { start: 0, end: 1 }, + { start: 2, end: 3 }, + { start: 4, end: 5 }, + ]); + // token 去重:query 重复词不重复计算。 + expect(keywordRanges('foo bar', 'foo foo')).toEqual([{ start: 0, end: 3 }]); + }); + + it('combining mark 后的 ASCII 仍按同源折叠命中', () => { + // I + U+0301 后接空格 ABC。映射与小写串必须来自同一次折叠, + // 否则上下文相关 locale 规则会让后面的 ABC 漏匹配。 + expect(keywordRanges('Í ABC', 'ABC')).toEqual([{ start: 3, end: 6 }]); + }); + + it('无命中返回空数组', () => { + expect(keywordRanges('hello world', 'xyz')).toEqual([]); + expect(keywordRanges('hello world', '***')).toEqual([]); + expect(keywordRanges('', 'abc')).toEqual([]); + }); +}); diff --git a/apps/desktop/src/renderer/features/cc-agent/sidebar/__tests__/snippetMarkup.test.ts b/apps/desktop/src/renderer/features/cc-agent/sidebar/__tests__/snippetMarkup.test.ts new file mode 100644 index 0000000000..3b9d1cf3dc --- /dev/null +++ b/apps/desktop/src/renderer/features/cc-agent/sidebar/__tests__/snippetMarkup.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { parseSnippetMarkup } from '../snippetMarkup'; + +describe('parseSnippetMarkup', () => { + it('空串 / null 返回 null,纯空白保留', () => { + expect(parseSnippetMarkup(null)).toBeNull(); + expect(parseSnippetMarkup('')).toBeNull(); + expect(parseSnippetMarkup(' ')).toEqual([{ text: ' ', marked: false }]); + }); + + it('默认当原文:字面 和实体都不解码', () => { + expect(parseSnippetMarkup('see <tag> here')).toEqual([ + { text: 'see <tag> here', marked: false }, + ]); + expect(parseSnippetMarkup('<tag>')).toEqual([ + { text: '<tag>', marked: false }, + ]); + expect(parseSnippetMarkup('see here 登录')).toEqual([ + { text: 'see here 登录', marked: false }, + ]); + }); + + it('protocol:不 trim 窗边缘空格', () => { + expect(parseSnippetMarkup(' 登录 ', { protocol: true })).toEqual([ + { text: ' ', marked: false }, + { text: '登录', marked: true }, + { text: ' ', marked: false }, + ]); + }); + + it('protocol:原文转义后的 还原成字面量,不被当成控制标记', () => { + expect( + parseSnippetMarkup('see <mark>here</mark> 登录', { protocol: true }), + ).toEqual([ + { text: 'see here ', marked: false }, + { text: '登录', marked: true }, + ]); + }); + + it('protocol:先还原 & 再还原 <,原文 < 显示为字面量 <', () => { + expect(parseSnippetMarkup('a & b &lt; c x', { protocol: true })).toEqual([ + { text: 'a & b < c ', marked: false }, + { text: 'x', marked: true }, + ]); + }); +}); diff --git a/apps/desktop/src/renderer/features/cc-agent/sidebar/keywordRanges.ts b/apps/desktop/src/renderer/features/cc-agent/sidebar/keywordRanges.ts new file mode 100644 index 0000000000..334cefbf49 --- /dev/null +++ b/apps/desktop/src/renderer/features/cc-agent/sidebar/keywordRanges.ts @@ -0,0 +1,80 @@ +/** + * 关键词高亮区间计算 —— renderer 侧搜索 snippet / preview 的 fallback 高亮。 + * + * 不能先 toLocaleLowerCase() 再拿折叠串的下标直接切原文:大小写折叠会改变 + * 长度(如 U+0130「İ」折叠为 i + U+0307 两个码点),折叠串与原文的下标体系 + * 失配,命中区间会错切甚至切出空标。 + * + * 做法:为折叠串的每个 UTF-16 单元记录「折叠偏移 → 原文 UTF-16 偏移」的映射, + * 命中偏移经映射表换算回原文坐标后再切。原文切点永远落在码点边界,不会切进 + * 代理对中间。 + */ +export interface KeywordRange { + /** 原文 UTF-16 起始下标(可直接用于 String.slice)。 */ + start: number; + /** 原文 UTF-16 结束下标(不含)。 */ + end: number; +} + +function rangesOverlap(a: KeywordRange, b: KeywordRange): boolean { + return a.start < b.end && b.start < a.end; +} + +export function keywordRanges(text: string, query: string): KeywordRange[] { + const tokens = [...new Set(query.match(/[\p{L}\p{N}]+/gu) ?? [])] + .map((token) => token.trim()) + .filter((token) => token.length === 0 ? false : true) + .sort((a, b) => b.length - a.length); + if (tokens.length === 0) return []; + + // foldOffsetToOrig16[k] = 折叠串第 k 个 UTF-16 单元对应的原文 UTF-16 偏移 + // (该码点的起始单元)。膨胀单元共享同一原文偏移,因此命中区间首尾都会 + // 对齐到码点边界。 + const { lowerText, foldOffsetToOrig16 } = foldWithOrigMap(text); + const ranges: KeywordRange[] = []; + for (const token of tokens) { + const lowerToken = token.toLowerCase(); + let index = lowerText.indexOf(lowerToken); + while (index >= 0) { + const start16 = foldOffsetToOrig16[index]; + const end16Raw = foldOffsetToOrig16[index + lowerToken.length - 1]; + // 折叠串与映射表长度一致;防御越界(例如不同 ICU 版本折叠差异)。 + if (start16 === undefined || end16Raw === undefined) break; + const next: KeywordRange = { + start: start16, + end: end16Raw + origEndLen(text, end16Raw), + }; + if (!ranges.some((range) => rangesOverlap(range, next)) && next.end > next.start) { + ranges.push(next); + } + index = lowerText.indexOf(lowerToken, index + lowerToken.length); + } + } + return ranges.sort((a, b) => a.start - b.start); +} + +/** 求原文在 UTF-16 偏移 pos 处的码点长度(1 或 2),用于把“起始偏移”换算成“结束偏移”。 */ +function origEndLen(text: string, pos: number): number { + const ch = text.codePointAt(pos) ?? 0; + return ch > 0xffff ? 2 : 1; +} + +/** + * 一次折叠同时产出小写串和「折叠 UTF-16 偏移 → 原文 UTF-16 偏移」映射。 + * 不用 toLocaleLowerCase():lt/tr 等 locale 有上下文相关规则,逐码点折叠 + * 与整串折叠可能不等长,后面的 ASCII 会漏匹配。 + */ +function foldWithOrigMap(text: string): { lowerText: string; foldOffsetToOrig16: number[] } { + const foldOffsetToOrig16: number[] = []; + let lowerText = ''; + let orig16 = 0; + for (const ch of text) { + const folded = ch.toLowerCase(); + lowerText += folded; + for (let k = 0; k < folded.length; k += 1) { + foldOffsetToOrig16.push(orig16); + } + orig16 += ch.length; + } + return { lowerText, foldOffsetToOrig16 }; +} diff --git a/apps/desktop/src/renderer/features/cc-agent/sidebar/snippetMarkup.ts b/apps/desktop/src/renderer/features/cc-agent/sidebar/snippetMarkup.ts new file mode 100644 index 0000000000..a587ba5eb6 --- /dev/null +++ b/apps/desktop/src/renderer/features/cc-agent/sidebar/snippetMarkup.ts @@ -0,0 +1,57 @@ +/** + * 搜索 snippet 的 `` 哨兵协议解析。 + * + * 生产端(buildSnippetFromContent)会把原文里的 `<` / `>` / `&` 转成实体后再 + * 插入 ``。消费端必须先按哨兵切开,再把实体还原,否则用户内容里的真 + * `` 会被当成控制标记,展示被改写。 + * + * 侧栏 `searchConversations` 的 snippet 是原文切片,不是这份协议。默认按原文 + * 返回,不因字面出现 `` 就解码实体。只有调用方明确传入 + * `{ protocol: true }` 时才按哨兵协议解析。 + * + * 不要 trim:窗边缘的原文空格也是用户内容。 + */ +export interface SnippetPart { + text: string; + marked: boolean; +} + +export interface ParseSnippetMarkupOptions { + /** 输入已按 buildSnippetFromContent 哨兵协议转义。默认 false:当原文。 */ + protocol?: boolean; +} + +export function parseSnippetMarkup( + snippet: string | null | undefined, + options?: ParseSnippetMarkupOptions, +): SnippetPart[] | null { + if (snippet == null || snippet === '') return null; + if (!options?.protocol) return [{ text: snippet, marked: false }]; + const parts = snippet.split(/(<\/?mark>)/g); + const out: SnippetPart[] = []; + let marked = false; + for (const part of parts) { + if (!part) continue; + if (part === '') { + marked = true; + continue; + } + if (part === '') { + marked = false; + continue; + } + out.push({ text: unescapeSnippetText(part), marked }); + } + return out.length > 0 ? out : [{ text: snippet, marked: false }]; +} + +/** 还原 buildSnippetFromContent 写入的 HTML 实体,让原文 `<` / `>` / `&` 原样展示。 */ +export function unescapeSnippetText(text: string): string { + // 必须先还原 < / >,再还原 &:原文 "<" 编码后是 "&lt;", + // 先解 lt 碰不到它(中间夹着 amp;),再解 amp 才得到字面量 "<"。 + // 若先解 amp,会把 "&lt;" 变成 "<" 再被解成 "<",原文实体就丢了。 + return text + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); +} diff --git a/docs/dev-rules/database-and-migrations.md b/docs/dev-rules/database-and-migrations.md index a43a44c89e..61bc2a8131 100644 --- a/docs/dev-rules/database-and-migrations.md +++ b/docs/dev-rules/database-and-migrations.md @@ -106,6 +106,13 @@ companion CommonJS 格式和历史 runtime identity 冻结;不能用单独 typ `prepare(...).all()` 之类的同步查询。 - 多步骤写操作需要原子性时使用已有命名事务/worker transaction,不在 Renderer 拼装 数据库流程。 +- **新客户端写 `messages` 的连接必须先注册 `cjk_seg`。** SQLite 自定义函数是连接级的, + 不进 schema。0100 起持久 `messages_fts` insert/update 触发器只写原文,并在 + `cjk_seg` 已注册时跳过;新连接打开后由 `registerCjkSeg` 挂 TEMP 触发器按字写入。 + 旧客户端看不到 TEMP、也不会注册该函数,插入仍成功,只是新行不再按字切。生产 + worker、migration runner、漂移修复都经由 `createBetterSqliteDatabase` / + `createWorkerDatabase` 注册。分词规格冻结在 `cjkSeg.ts`(只收 + `\p{Script=Han}`);改这个函数必须配新的重建 migration。 ## Review 清单 diff --git a/packages/lizi-mcps/src/__tests__/searchChatHistoryTool.test.ts b/packages/lizi-mcps/src/__tests__/searchChatHistoryTool.test.ts index fa752bbe31..591552f894 100644 --- a/packages/lizi-mcps/src/__tests__/searchChatHistoryTool.test.ts +++ b/packages/lizi-mcps/src/__tests__/searchChatHistoryTool.test.ts @@ -54,6 +54,14 @@ function parse(result: XdtHelperToolResult) { } describe('search_chat_history tool', () => { + it('超长 query → INVALID_ARGS (zod)', async () => { + const { registry, searchChatHistory } = setup(makeResult()); + const res = await registry.call('search_chat_history', { query: '边'.repeat(257) }); + expect(res.isError).toBe(true); + expect(parse(res)).toMatchObject({ ok: false, errorCode: 'INVALID_ARGS' }); + expect(searchChatHistory).not.toHaveBeenCalled(); + }); + it('缺 query → INVALID_ARGS (zod)', async () => { const { registry, searchChatHistory } = setup(makeResult()); const res = await registry.call('search_chat_history', {}); diff --git a/packages/lizi-mcps/src/memory/sessionSearch.ts b/packages/lizi-mcps/src/memory/sessionSearch.ts index 64b1798d3e..878f87d6b4 100644 --- a/packages/lizi-mcps/src/memory/sessionSearch.ts +++ b/packages/lizi-mcps/src/memory/sessionSearch.ts @@ -39,7 +39,7 @@ export function registerSessionSearchTool( ' 跟 memory_search 区别: memory_search 搜提炼后的 memory 分片, session_search 搜原始对话。' + ' 用于 "上次怎么解决这个 bug" / "用户提到过 X 在哪轮" 类场景。', inputShape: { - query: z.string().min(1).describe('搜索关键词 (短语精确匹配)'), + query: z.string().min(1).max(256).describe('搜索关键词 (中文按字相邻匹配, 多词 AND, 最长 256)'), sessionId: z.string().optional().describe('限定某 session'), role: z.enum(['user', 'assistant', 'system']).optional().describe('限定 message role'), limit: z.number().int().min(1).max(50).optional().describe('默认 10'), diff --git a/packages/lizi-mcps/src/xdt-helper/search_chat_history.ts b/packages/lizi-mcps/src/xdt-helper/search_chat_history.ts index 29d74f51e1..b70b7e27d6 100644 --- a/packages/lizi-mcps/src/xdt-helper/search_chat_history.ts +++ b/packages/lizi-mcps/src/xdt-helper/search_chat_history.ts @@ -82,7 +82,7 @@ export function registerSearchChatHistoryTool( category: 'history', description: DESCRIPTION, inputShape: { - query: z.string().min(1).describe('自然语言查询(必填)。例: "上次怎么修的语音输入授权问题"。'), + query: z.string().min(1).max(256).describe('自然语言查询(必填, 最长 256)。例: "上次怎么修的语音输入授权问题"。'), session_ids: z .array(z.string().min(1)) .max(50) diff --git a/packages/maker-core/src/agents/pi/windows-git-path-powershell.test.ts b/packages/maker-core/src/agents/pi/windows-git-path-powershell.test.ts index 6d1c854f80..9b68b19bb2 100644 --- a/packages/maker-core/src/agents/pi/windows-git-path-powershell.test.ts +++ b/packages/maker-core/src/agents/pi/windows-git-path-powershell.test.ts @@ -146,6 +146,14 @@ describe('Windows Git PATH PowerShell probes', () => { throw error; } + // 子 powershell 的 1250ms 操作预算覆盖冷启动 + 探测;冷缓存/杀软扫描下 + // 冷启动就能超预算,coordinator 按设计把过慢操作记成诊断并截断输出。 + // 这是环境速度问题,与整体 ETIMEDOUT 同口径 skip,不判脚本失败。 + if (countWindowsPowerShellDiagnostics(output) > 0) { + skip(); + return; + } + expect(output).toContain(`D\t${Buffer.from(validCmd, 'utf16le').toString('base64')}`); expect(output).toContain(`F\t${Buffer.from(validGit, 'utf16le').toString('base64')}`); } finally { @@ -234,9 +242,9 @@ describe('Windows Git PATH PowerShell probes', () => { '}', ].join('\n'), ], - // PowerShell startup can exceed five seconds on a busy hosted Windows runner; - // allow cleanup to observe the child process exit without changing the probe budget. - { stdio: 'ignore', timeout: 10_000, windowsHide: true }, + // 存活判定期限仍是脚本内的 3s deadline;PowerShell 冷启动在满载 + // runner 上可超五秒,放宽上限避免把启动慢误判成清理失败。 + { stdio: 'ignore', timeout: 10_000, windowsHide: true }, ); } finally { if (coordinatorPid) { @@ -253,7 +261,9 @@ describe('Windows Git PATH PowerShell probes', () => { } } }, - 20_000, + // waitFor(5s) + 后代清理(8s 上限) + 存活检查(10s 上限)相加的预算上限; + // 各段正常完成都在 1-2s 内,这只是满载 CI 上不被 vitest 先杀掉。 + 25_000, ); it('reports recoverable script failures only when a logger is supplied', () => { diff --git a/packages/maker-core/src/agents/pi/windows-git-path-powershell.ts b/packages/maker-core/src/agents/pi/windows-git-path-powershell.ts index a07729f244..b9b9092801 100644 --- a/packages/maker-core/src/agents/pi/windows-git-path-powershell.ts +++ b/packages/maker-core/src/agents/pi/windows-git-path-powershell.ts @@ -5,7 +5,10 @@ import type { Logger } from '../../interfaces/logger.js'; export type WindowsGitPathLogger = Pick; const DIAGNOSTIC_PREFIX = '__CINDY_WINDOWS_GIT_PATH_DIAGNOSTIC__'; -const WINDOWS_DESCENDANT_CLEANUP_TIMEOUT_MS = 2_000; +// 清理自身要付一次 powershell 冷启动 + Win32_Process BFS。满载机器上 2s 预算 +// 会在 Stop-Process 之前超时,孤儿后代被静默留下;清理只发生在 probe 失败的 +// 收尾路径,多给几秒不占任何交互预算。 +const WINDOWS_DESCENDANT_CLEANUP_TIMEOUT_MS = 8_000; type WindowsPowerShellProbe = 'registry' | 'network-drives' | 'path-kinds' | 'path-cleanup';