diff --git a/.sqlx/query-16d83cb7e55768d51a99ae240dac3d305c7696877b7d1126269dadc2166ff712.json b/.sqlx/query-16d83cb7e55768d51a99ae240dac3d305c7696877b7d1126269dadc2166ff712.json new file mode 100644 index 0000000..2f69ae9 --- /dev/null +++ b/.sqlx/query-16d83cb7e55768d51a99ae240dac3d305c7696877b7d1126269dadc2166ff712.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT session_id FROM analytics_requests WHERE id = ?", + "describe": { + "columns": [ + { + "name": "session_id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "16d83cb7e55768d51a99ae240dac3d305c7696877b7d1126269dadc2166ff712" +} diff --git a/.sqlx/query-1f8ef75ee4ce329f878ad852323147d541ee8f69506fbc36386ea34a5c9f6da8.json b/.sqlx/query-1f8ef75ee4ce329f878ad852323147d541ee8f69506fbc36386ea34a5c9f6da8.json new file mode 100644 index 0000000..88d0e31 --- /dev/null +++ b/.sqlx/query-1f8ef75ee4ce329f878ad852323147d541ee8f69506fbc36386ea34a5c9f6da8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO analytics (\n id, analytics_request_id, session_id, generated_at,\n qualitative_output_json,\n ai_quantitative_output_json,\n metric_quantitative_output_json,\n model_used, analysis_duration_ms\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "1f8ef75ee4ce329f878ad852323147d541ee8f69506fbc36386ea34a5c9f6da8" +} diff --git a/.sqlx/query-52ec7d328d2f43ace95296caa380aeac9b75704263ada069cb87c6faa6cb4a7d.json b/.sqlx/query-52ec7d328d2f43ace95296caa380aeac9b75704263ada069cb87c6faa6cb4a7d.json new file mode 100644 index 0000000..fca33d6 --- /dev/null +++ b/.sqlx/query-52ec7d328d2f43ace95296caa380aeac9b75704263ada069cb87c6faa6cb4a7d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO analytics_requests (\n id, session_id, status, started_at, completed_at,\n created_by, error_message, custom_prompt\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "52ec7d328d2f43ace95296caa380aeac9b75704263ada069cb87c6faa6cb4a7d" +} diff --git a/.sqlx/query-70b55d0a9faf7bea3ba229c92f5e6f81107d887d43ca950bd85c7a7081cd96fa.json b/.sqlx/query-70b55d0a9faf7bea3ba229c92f5e6f81107d887d43ca950bd85c7a7081cd96fa.json new file mode 100644 index 0000000..927d6da --- /dev/null +++ b/.sqlx/query-70b55d0a9faf7bea3ba229c92f5e6f81107d887d43ca950bd85c7a7081cd96fa.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) as count FROM analytics_requests WHERE status = ?", + "describe": { + "columns": [ + { + "name": "count", + "ordinal": 0, + "type_info": "Int" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "70b55d0a9faf7bea3ba229c92f5e6f81107d887d43ca950bd85c7a7081cd96fa" +} diff --git a/.sqlx/query-76fa1a7b667a1c5a8e5cbb355261cb716bc1f40e8035eec1289702a2287e574a.json b/.sqlx/query-76fa1a7b667a1c5a8e5cbb355261cb716bc1f40e8035eec1289702a2287e574a.json new file mode 100644 index 0000000..a8ed88b --- /dev/null +++ b/.sqlx/query-76fa1a7b667a1c5a8e5cbb355261cb716bc1f40e8035eec1289702a2287e574a.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id, analytics_request_id, session_id, generated_at,\n qualitative_output_json,\n ai_quantitative_output_json,\n metric_quantitative_output_json,\n model_used, analysis_duration_ms\n FROM analytics\n WHERE analytics_request_id = ?\n ORDER BY generated_at DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "analytics_request_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "generated_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "qualitative_output_json", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "ai_quantitative_output_json", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "metric_quantitative_output_json", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "model_used", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "analysis_duration_ms", + "ordinal": 8, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + true, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "76fa1a7b667a1c5a8e5cbb355261cb716bc1f40e8035eec1289702a2287e574a" +} diff --git a/.sqlx/query-85e79f8bee1545849066899470728dac9d6f71b720b2488ab112584e0ee19a03.json b/.sqlx/query-85e79f8bee1545849066899470728dac9d6f71b720b2488ab112584e0ee19a03.json new file mode 100644 index 0000000..26e5990 --- /dev/null +++ b/.sqlx/query-85e79f8bee1545849066899470728dac9d6f71b720b2488ab112584e0ee19a03.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) as count FROM analytics_requests WHERE status IN ('pending', 'running')", + "describe": { + "columns": [ + { + "name": "count", + "ordinal": 0, + "type_info": "Int" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + null + ] + }, + "hash": "85e79f8bee1545849066899470728dac9d6f71b720b2488ab112584e0ee19a03" +} diff --git a/.sqlx/query-a3a2bf6d2877a712b961a3dfc1d732b85a27cf0f1453e86ede0e179608f5830c.json b/.sqlx/query-a3a2bf6d2877a712b961a3dfc1d732b85a27cf0f1453e86ede0e179608f5830c.json new file mode 100644 index 0000000..e838eba --- /dev/null +++ b/.sqlx/query-a3a2bf6d2877a712b961a3dfc1d732b85a27cf0f1453e86ede0e179608f5830c.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM analytics_requests WHERE status = ? ORDER BY started_at DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "started_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "completed_at", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_by", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "error_message", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "custom_prompt", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "a3a2bf6d2877a712b961a3dfc1d732b85a27cf0f1453e86ede0e179608f5830c" +} diff --git a/.sqlx/query-a60c6fbf087d65237d635258999d4357ba7381aafb7b0efc8aefd8727ac7e786.json b/.sqlx/query-a60c6fbf087d65237d635258999d4357ba7381aafb7b0efc8aefd8727ac7e786.json new file mode 100644 index 0000000..86e1da5 --- /dev/null +++ b/.sqlx/query-a60c6fbf087d65237d635258999d4357ba7381aafb7b0efc8aefd8727ac7e786.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM analytics_requests WHERE started_at >= ? ORDER BY started_at DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "started_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "completed_at", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_by", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "error_message", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "custom_prompt", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "a60c6fbf087d65237d635258999d4357ba7381aafb7b0efc8aefd8727ac7e786" +} diff --git a/.sqlx/query-abf269f1fdf77d92aeb10e953fa8f5278519ddb5f70b25b2a0c48231add2bace.json b/.sqlx/query-abf269f1fdf77d92aeb10e953fa8f5278519ddb5f70b25b2a0c48231add2bace.json new file mode 100644 index 0000000..d1d6421 --- /dev/null +++ b/.sqlx/query-abf269f1fdf77d92aeb10e953fa8f5278519ddb5f70b25b2a0c48231add2bace.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM analytics_requests ORDER BY started_at DESC LIMIT ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "started_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "completed_at", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_by", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "error_message", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "custom_prompt", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "abf269f1fdf77d92aeb10e953fa8f5278519ddb5f70b25b2a0c48231add2bace" +} diff --git a/.sqlx/query-b090411c4cb1bd288c5f024dbb7cb87bd624a286ee2e4efdd2932937ea3ce63f.json b/.sqlx/query-b090411c4cb1bd288c5f024dbb7cb87bd624a286ee2e4efdd2932937ea3ce63f.json new file mode 100644 index 0000000..cbd27ff --- /dev/null +++ b/.sqlx/query-b090411c4cb1bd288c5f024dbb7cb87bd624a286ee2e4efdd2932937ea3ce63f.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM analytics_requests WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "started_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "completed_at", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_by", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "error_message", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "custom_prompt", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "b090411c4cb1bd288c5f024dbb7cb87bd624a286ee2e4efdd2932937ea3ce63f" +} diff --git a/.sqlx/query-b9301b4ad8601f33efc34fa29d2da353b93e6c0002f1ea5eaec6937fc93486ad.json b/.sqlx/query-b9301b4ad8601f33efc34fa29d2da353b93e6c0002f1ea5eaec6937fc93486ad.json new file mode 100644 index 0000000..139a72e --- /dev/null +++ b/.sqlx/query-b9301b4ad8601f33efc34fa29d2da353b93e6c0002f1ea5eaec6937fc93486ad.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM analytics_requests WHERE created_by = ? ORDER BY started_at DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "started_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "completed_at", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_by", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "error_message", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "custom_prompt", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "b9301b4ad8601f33efc34fa29d2da353b93e6c0002f1ea5eaec6937fc93486ad" +} diff --git a/.sqlx/query-cc2b8d306223e34b8105cf775d9b14c366b42454be105f69933e49287d2728db.json b/.sqlx/query-cc2b8d306223e34b8105cf775d9b14c366b42454be105f69933e49287d2728db.json new file mode 100644 index 0000000..43f052d --- /dev/null +++ b/.sqlx/query-cc2b8d306223e34b8105cf775d9b14c366b42454be105f69933e49287d2728db.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM analytics_requests WHERE status IN ('pending', 'running') ORDER BY started_at ASC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "started_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "completed_at", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_by", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "error_message", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "custom_prompt", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "cc2b8d306223e34b8105cf775d9b14c366b42454be105f69933e49287d2728db" +} diff --git a/.sqlx/query-e09e456b7afa3cf68557e2daa8cfb499f84b7a13b2eb745c468e38a8f35ec58c.json b/.sqlx/query-e09e456b7afa3cf68557e2daa8cfb499f84b7a13b2eb745c468e38a8f35ec58c.json new file mode 100644 index 0000000..4b3205c --- /dev/null +++ b/.sqlx/query-e09e456b7afa3cf68557e2daa8cfb499f84b7a13b2eb745c468e38a8f35ec58c.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM analytics_requests WHERE session_id = ? ORDER BY started_at DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "started_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "completed_at", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_by", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "error_message", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "custom_prompt", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "e09e456b7afa3cf68557e2daa8cfb499f84b7a13b2eb745c468e38a8f35ec58c" +} diff --git a/.sqlx/query-f64b389d270e02d0d2111c50691ebb2209038b90dafd66a4e5d59b5b7c3b4746.json b/.sqlx/query-f64b389d270e02d0d2111c50691ebb2209038b90dafd66a4e5d59b5b7c3b4746.json new file mode 100644 index 0000000..263fa05 --- /dev/null +++ b/.sqlx/query-f64b389d270e02d0d2111c50691ebb2209038b90dafd66a4e5d59b5b7c3b4746.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM analytics_requests WHERE completed_at IS NOT NULL AND completed_at < ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "f64b389d270e02d0d2111c50691ebb2209038b90dafd66a4e5d59b5b7c3b4746" +} diff --git a/.sqlx/query-fa1149b5d14759d111418649428d7779bf47c7d24a2150b8cdcf025f15482b98.json b/.sqlx/query-fa1149b5d14759d111418649428d7779bf47c7d24a2150b8cdcf025f15482b98.json new file mode 100644 index 0000000..106af1f --- /dev/null +++ b/.sqlx/query-fa1149b5d14759d111418649428d7779bf47c7d24a2150b8cdcf025f15482b98.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM analytics_requests WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "fa1149b5d14759d111418649428d7779bf47c7d24a2150b8cdcf025f15482b98" +} diff --git a/.sqlx/query-fc4e5e50bba17e73854cd3e68d50b0815935788644dc0ef54ea7f872cd3d73ce.json b/.sqlx/query-fc4e5e50bba17e73854cd3e68d50b0815935788644dc0ef54ea7f872cd3d73ce.json new file mode 100644 index 0000000..cdadcdb --- /dev/null +++ b/.sqlx/query-fc4e5e50bba17e73854cd3e68d50b0815935788644dc0ef54ea7f872cd3d73ce.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id, analytics_request_id, session_id, generated_at,\n qualitative_output_json,\n ai_quantitative_output_json,\n metric_quantitative_output_json,\n model_used, analysis_duration_ms\n FROM analytics\n WHERE id = ?\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "analytics_request_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "generated_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "qualitative_output_json", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "ai_quantitative_output_json", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "metric_quantitative_output_json", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "model_used", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "analysis_duration_ms", + "ordinal": 8, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + true, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "fc4e5e50bba17e73854cd3e68d50b0815935788644dc0ef54ea7f872cd3d73ce" +} diff --git a/crates/retrochat-core/migrations/017_add_slash_command_message_type.sql b/crates/retrochat-core/migrations/017_add_slash_command_message_type.sql new file mode 100644 index 0000000..d41340b --- /dev/null +++ b/crates/retrochat-core/migrations/017_add_slash_command_message_type.sql @@ -0,0 +1,69 @@ +-- Add slash_command message type +-- Migration: 017_add_slash_command_message_type +-- Description: Add 'slash_command' to message_type CHECK constraint to support Claude Code slash command messages + +-- Drop the old constraint and add the new one with 'slash_command' +-- SQLite doesn't support ALTER TABLE ... DROP CONSTRAINT, so we need to recreate the table + +-- Create a temporary table with the new schema +CREATE TABLE messages_new ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('User', 'Assistant', 'System')), + content TEXT NOT NULL CHECK (length(content) > 0), + timestamp TEXT NOT NULL, + token_count INTEGER CHECK (token_count >= 0), + metadata TEXT, -- JSON object + sequence_number INTEGER NOT NULL, + message_type TEXT NOT NULL DEFAULT 'simple_message' CHECK (message_type IN ('tool_request', 'tool_result', 'thinking', 'slash_command', 'simple_message')), + tool_operation_id TEXT, + FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (tool_operation_id) REFERENCES tool_operations(id) ON DELETE SET NULL, + UNIQUE(session_id, sequence_number) +); + +-- Copy data from old table +INSERT INTO messages_new (id, session_id, role, content, timestamp, token_count, metadata, sequence_number, message_type, tool_operation_id) +SELECT id, session_id, role, content, timestamp, token_count, metadata, sequence_number, message_type, tool_operation_id +FROM messages; + +-- Drop old table +DROP TABLE messages; + +-- Rename new table to messages +ALTER TABLE messages_new RENAME TO messages; + +-- Recreate indexes +CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id); +CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role); +CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp); +CREATE INDEX IF NOT EXISTS idx_messages_sequence ON messages(session_id, sequence_number); +CREATE INDEX IF NOT EXISTS idx_messages_message_type ON messages(message_type); +CREATE INDEX IF NOT EXISTS idx_messages_tool_operation ON messages(tool_operation_id); + +-- Rebuild FTS index after table recreation +-- First, delete and recreate the FTS table +DROP TABLE IF EXISTS messages_fts; + +CREATE VIRTUAL TABLE messages_fts USING fts5( + content, + session_id UNINDEXED, + role UNINDEXED, + timestamp UNINDEXED, + content='messages', + content_rowid='rowid' +); + +-- Recreate FTS triggers +CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, content) VALUES (NEW.rowid, NEW.content); +END; + +CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', OLD.rowid, OLD.content); +END; + +CREATE TRIGGER messages_fts_update AFTER UPDATE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', OLD.rowid, OLD.content); + INSERT INTO messages_fts(rowid, content) VALUES (NEW.rowid, NEW.content); +END; diff --git a/crates/retrochat-core/src/models/message.rs b/crates/retrochat-core/src/models/message.rs index ac8e8b2..f5866fc 100644 --- a/crates/retrochat-core/src/models/message.rs +++ b/crates/retrochat-core/src/models/message.rs @@ -15,10 +15,24 @@ pub enum MessageType { ToolRequest, ToolResult, Thinking, + SlashCommand, #[default] SimpleMessage, } +/// Parsed slash command from XML blocks (e.g., /clear, /help) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlashCommandData { + /// The command name (e.g., "/clear", "/help") + pub command_name: String, + /// The command message/description + pub command_message: Option, + /// Command arguments if any + pub command_args: Option, + /// Command stdout result + pub stdout: Option, +} + impl std::fmt::Display for MessageRole { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -48,6 +62,7 @@ impl std::fmt::Display for MessageType { MessageType::ToolRequest => write!(f, "tool_request"), MessageType::ToolResult => write!(f, "tool_result"), MessageType::Thinking => write!(f, "thinking"), + MessageType::SlashCommand => write!(f, "slash_command"), MessageType::SimpleMessage => write!(f, "simple_message"), } } @@ -61,6 +76,7 @@ impl std::str::FromStr for MessageType { "tool_request" => Ok(MessageType::ToolRequest), "tool_result" => Ok(MessageType::ToolResult), "thinking" => Ok(MessageType::Thinking), + "slash_command" => Ok(MessageType::SlashCommand), "simple_message" => Ok(MessageType::SimpleMessage), _ => Err(format!("Unknown message type: {s}")), } @@ -219,6 +235,11 @@ impl Message { matches!(self.message_type, MessageType::Thinking) } + /// Check if this message is a slash command + pub fn is_slash_command(&self) -> bool { + matches!(self.message_type, MessageType::SlashCommand) + } + /// Check if this message has an associated tool operation pub fn has_tool_operation(&self) -> bool { self.tool_operation_id.is_some() diff --git a/crates/retrochat-core/src/parsers/claude_code.rs b/crates/retrochat-core/src/parsers/claude_code.rs index 416c08c..9b9dbf6 100644 --- a/crates/retrochat-core/src/parsers/claude_code.rs +++ b/crates/retrochat-core/src/parsers/claude_code.rs @@ -1,5 +1,7 @@ use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Utc}; +use lazy_static::lazy_static; +use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::fs::File; @@ -7,12 +9,24 @@ use std::io::{BufRead, BufReader, Lines}; use std::path::Path; use uuid::Uuid; -use crate::models::message::{MessageType, ToolResult, ToolUse}; +use crate::models::message::{MessageType, SlashCommandData, ToolResult, ToolUse}; use crate::models::{ChatSession, Message, MessageRole}; use crate::models::{Provider, SessionState}; use super::project_inference::ProjectInference; +lazy_static! { + /// Regex patterns for parsing XML command blocks + static ref COMMAND_NAME_RE: Regex = + Regex::new(r"(.*?)").unwrap(); + static ref COMMAND_MESSAGE_RE: Regex = + Regex::new(r"(.*?)").unwrap(); + static ref COMMAND_ARGS_RE: Regex = + Regex::new(r"(.*?)").unwrap(); + static ref COMMAND_STDOUT_RE: Regex = + Regex::new(r"(?s)(.*?)").unwrap(); +} + #[derive(Debug, Serialize, Deserialize)] pub struct ClaudeCodeMessage { pub uuid: String, @@ -231,7 +245,7 @@ impl ClaudeCodeParser { .and_then(|ts| self.parse_timestamp(ts).ok()) .unwrap_or(start_time); - let (content, tool_uses, mut tool_results, thinking_content) = + let (content, tool_uses, mut tool_results, thinking_content, is_slash_command) = self.extract_tools_and_content(&conv_message.content); // If there's thinking content, create a separate message for it first @@ -265,8 +279,11 @@ impl ClaudeCodeParser { } } - // Skip messages with no meaningful content and no tools - if content == "[No content]" && tool_uses.is_empty() && tool_results.is_empty() + // Skip messages with no meaningful content and no tools and no slash command + if content == "[No content]" + && tool_uses.is_empty() + && tool_results.is_empty() + && !is_slash_command { continue; } @@ -275,6 +292,11 @@ impl ClaudeCodeParser { message.id = message_id; + // Set message type for slash commands + if is_slash_command { + message = message.with_message_type(MessageType::SlashCommand); + } + // Attach tool uses and results if any if !tool_uses.is_empty() { message = message.with_tool_uses(tool_uses); @@ -306,12 +328,61 @@ impl ClaudeCodeParser { Ok((chat_session, messages)) } + /// Parse XML slash command blocks from message content + /// Returns (is_slash_command, SlashCommandData if found) + fn parse_slash_command(&self, content: &str) -> Option { + // Check if content contains command-name tag + let command_name = COMMAND_NAME_RE + .captures(content) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string())?; + + let command_message = COMMAND_MESSAGE_RE + .captures(content) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) + .filter(|s| !s.is_empty()); + + let command_args = COMMAND_ARGS_RE + .captures(content) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) + .filter(|s| !s.is_empty()); + + let stdout = COMMAND_STDOUT_RE + .captures(content) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) + .filter(|s| !s.is_empty()); + + Some(SlashCommandData { + command_name, + command_message, + command_args, + stdout, + }) + } + + /// Formats SlashCommandData into a clean, human-readable string + fn format_slash_command_content(&self, cmd: &SlashCommandData) -> String { + let mut parts = vec![cmd.command_name.clone()]; + + if let Some(ref stdout) = cmd.stdout { + let trimmed = stdout.trim(); + if !trimmed.is_empty() { + parts.push(trimmed.to_string()); + } + } + + parts.join("\n") + } + /// Extract tools and content from a Claude Code message value - /// Returns (content_string, tool_uses, tool_results, thinking_content) + /// Returns (content_string, tool_uses, tool_results, thinking_content, is_slash_command) fn extract_tools_and_content( &self, value: &Value, - ) -> (String, Vec, Vec, Option) { + ) -> (String, Vec, Vec, Option, bool) { let mut tool_uses = Vec::new(); let mut tool_results = Vec::new(); let mut thinking_content: Option = None; @@ -418,14 +489,25 @@ impl ClaudeCodeParser { _ => value.to_string(), }; - // Ensure content is never empty to satisfy database constraint - let final_content = if content.trim().is_empty() { + // Check for slash command blocks and format content if found + let slash_command = self.parse_slash_command(&content); + + // Format content: use formatted slash command or original content + let final_content = if let Some(ref cmd) = slash_command { + self.format_slash_command_content(cmd) + } else if content.trim().is_empty() { "[No content]".to_string() } else { content }; - (final_content, tool_uses, tool_results, thinking_content) + ( + final_content, + tool_uses, + tool_results, + thinking_content, + slash_command.is_some(), + ) } fn convert_session( @@ -508,7 +590,7 @@ impl ClaudeCodeParser { _ => return Err(anyhow!("Unknown message role: {}", claude_message.role)), }; - let (content, tool_uses, tool_results, _thinking_content) = + let (content, tool_uses, tool_results, _thinking_content, is_slash_command) = self.extract_tools_and_content(&claude_message.content); // Note: thinking_content is ignored for legacy format @@ -518,6 +600,11 @@ impl ClaudeCodeParser { message.id = message_id; + // Set message type for slash commands + if is_slash_command { + message = message.with_message_type(MessageType::SlashCommand); + } + // Attach tool uses and results if any if !tool_uses.is_empty() { message = message.with_tool_uses(tool_uses); @@ -890,4 +977,98 @@ mod tests { // Should have inferred the project name from the path assert_eq!(session.project_name, Some("testproject".to_string())); } + + #[test] + fn test_parse_slash_command_clear() { + let content = "/clear\nclear\n\nCleared conversation history."; + let parser = ClaudeCodeParser::new(std::path::Path::new("test.jsonl")); + let result = parser.parse_slash_command(content); + + assert!(result.is_some()); + let cmd = result.unwrap(); + assert_eq!(cmd.command_name, "/clear"); + assert_eq!(cmd.command_message, Some("clear".to_string())); + assert!(cmd.command_args.is_none()); // Empty args should be None + assert_eq!( + cmd.stdout, + Some("Cleared conversation history.".to_string()) + ); + } + + #[test] + fn test_parse_slash_command_help() { + let content = "/help\nhelp\n"; + let parser = ClaudeCodeParser::new(std::path::Path::new("test.jsonl")); + let result = parser.parse_slash_command(content); + + assert!(result.is_some()); + let cmd = result.unwrap(); + assert_eq!(cmd.command_name, "/help"); + assert_eq!(cmd.command_message, Some("help".to_string())); + assert!(cmd.command_args.is_none()); + assert!(cmd.stdout.is_none()); + } + + #[test] + fn test_parse_slash_command_with_args() { + let content = "/model\nmodel\nopus"; + let parser = ClaudeCodeParser::new(std::path::Path::new("test.jsonl")); + let result = parser.parse_slash_command(content); + + assert!(result.is_some()); + let cmd = result.unwrap(); + assert_eq!(cmd.command_name, "/model"); + assert_eq!(cmd.command_message, Some("model".to_string())); + assert_eq!(cmd.command_args, Some("opus".to_string())); + assert!(cmd.stdout.is_none()); + } + + #[test] + fn test_parse_slash_command_multiline_stdout() { + let content = "/doctor\ndoctor\n\nLine 1\nLine 2\nLine 3"; + let parser = ClaudeCodeParser::new(std::path::Path::new("test.jsonl")); + let result = parser.parse_slash_command(content); + + assert!(result.is_some()); + let cmd = result.unwrap(); + assert_eq!(cmd.command_name, "/doctor"); + assert!(cmd.stdout.is_some()); + assert!(cmd.stdout.unwrap().contains('\n')); + } + + #[test] + fn test_parse_slash_command_no_command() { + let content = "This is just regular text without any command blocks."; + let parser = ClaudeCodeParser::new(std::path::Path::new("test.jsonl")); + let result = parser.parse_slash_command(content); + + assert!(result.is_none()); + } + + #[test] + fn test_message_type_slash_command_display() { + use crate::models::message::MessageType; + let mt = MessageType::SlashCommand; + assert_eq!(mt.to_string(), "slash_command"); + } + + #[test] + fn test_message_type_slash_command_from_str() { + use crate::models::message::MessageType; + use std::str::FromStr; + + let mt = MessageType::from_str("slash_command").unwrap(); + assert_eq!(mt, MessageType::SlashCommand); + } + + #[test] + fn test_message_type_slash_command_roundtrip() { + use crate::models::message::MessageType; + use std::str::FromStr; + + let original = MessageType::SlashCommand; + let serialized = original.to_string(); + let deserialized = MessageType::from_str(&serialized).unwrap(); + assert_eq!(original, deserialized); + } } diff --git a/crates/retrochat-core/src/services/analytics/data_collector.rs b/crates/retrochat-core/src/services/analytics/data_collector.rs index a33289a..714957d 100644 --- a/crates/retrochat-core/src/services/analytics/data_collector.rs +++ b/crates/retrochat-core/src/services/analytics/data_collector.rs @@ -143,6 +143,7 @@ fn get_message_type_string( format!("tool_result({})", tool_name) } MessageType::Thinking => "thinking".to_string(), + MessageType::SlashCommand => "slash_command".to_string(), MessageType::SimpleMessage => "simple_message".to_string(), } } diff --git a/crates/retrochat-core/src/services/query_service.rs b/crates/retrochat-core/src/services/query_service.rs index 079312e..2b1aff9 100644 --- a/crates/retrochat-core/src/services/query_service.rs +++ b/crates/retrochat-core/src/services/query_service.rs @@ -13,11 +13,11 @@ use uuid::Uuid; #[derive(Debug, Clone)] pub enum MessageGroup { /// A single standalone message - Single(Message), + Single(Box), /// A tool use message paired with its corresponding tool result message ToolPair { - tool_use_message: Message, - tool_result_message: Message, + tool_use_message: Box, + tool_result_message: Box, }, } @@ -70,8 +70,8 @@ impl MessageGroup { if has_matching_result { // Create a ToolPair and skip the next message groups.push(MessageGroup::ToolPair { - tool_use_message: current.clone(), - tool_result_message: next.clone(), + tool_use_message: Box::new(current.clone()), + tool_result_message: Box::new(next.clone()), }); i += 2; // Skip both messages continue; @@ -81,7 +81,7 @@ impl MessageGroup { } // Not a pair, add as single - groups.push(MessageGroup::Single(current.clone())); + groups.push(MessageGroup::Single(Box::new(current.clone()))); i += 1; } diff --git a/crates/retrochat-tui/src/session_detail.rs b/crates/retrochat-tui/src/session_detail.rs index db950d4..dec6b42 100644 --- a/crates/retrochat-tui/src/session_detail.rs +++ b/crates/retrochat-tui/src/session_detail.rs @@ -352,8 +352,9 @@ impl SessionDetailWidget { /// Renders a single message block fn render_message_block(&self, message: &Message, width: usize, lines: &mut Vec>) { - // Check if this is a thinking message + // Check message types let is_thinking = message.is_thinking(); + let is_slash_command = message.is_slash_command(); // Message header let role_style = if is_thinking { @@ -361,6 +362,11 @@ impl SessionDetailWidget { Style::default() .fg(Color::Magenta) .add_modifier(Modifier::BOLD | Modifier::ITALIC) + } else if is_slash_command { + // Slash command messages have yellow/amber styling + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { match message.role { MessageRole::User => Style::default() @@ -378,6 +384,8 @@ impl SessionDetailWidget { let timestamp = &message.timestamp.format("%H:%M:%S").to_string(); // Just show time let header = if is_thinking { format!("[{timestamp}] Thinking") + } else if is_slash_command { + format!("[{timestamp}] Slash Command") } else { format!("[{timestamp}] {:?}", message.role) }; @@ -390,11 +398,13 @@ impl SessionDetailWidget { // Message content - wrap text and preserve newlines let content_lines = wrap_text(&message.content, width.saturating_sub(2)); - // Use different styling for thinking content + // Use different styling for thinking and slash command content let content_style = if is_thinking { Style::default() .fg(Color::Rgb(180, 140, 200)) // Light purple for thinking .add_modifier(Modifier::ITALIC) + } else if is_slash_command { + Style::default().fg(Color::Rgb(200, 180, 100)) // Golden/amber for slash commands } else { Style::default().fg(Color::White) }; diff --git a/ui-react/src/components/session-detail.tsx b/ui-react/src/components/session-detail.tsx index 7158f23..516e9b6 100644 --- a/ui-react/src/components/session-detail.tsx +++ b/ui-react/src/components/session-detail.tsx @@ -6,7 +6,16 @@ import { ReloadIcon, } from '@radix-ui/react-icons' import { format, formatDistanceToNow } from 'date-fns' -import { Bot, Brain, Check, Copy, FileCode, MessageSquare, TrendingUp } from 'lucide-react' +import { + Bot, + Brain, + Check, + Copy, + FileCode, + MessageSquare, + Terminal, + TrendingUp, +} from 'lucide-react' import { useCallback, useEffect, useState } from 'react' import { useHotkeys } from 'react-hotkeys-hook' import ReactMarkdown from 'react-markdown' @@ -149,6 +158,10 @@ function MessageRenderer({ message }: { message: SessionWithMessages['messages'] return } + if (messageType === 'slash_command') { + return + } + return } @@ -194,6 +207,57 @@ function ThinkingMessage({ message }: { message: SessionWithMessages['messages'] ) } +function SlashCommandMessage({ message }: { message: SessionWithMessages['messages'][0] }) { + const [isOpen, setIsOpen] = useState(false) + + // Extract command name from content if available + const commandName = + message.content.match(/\[Slash Command: (.*?)\]/)?.[1] || + message.content.match(/(.*?)<\/command-name>/)?.[1] || + 'Command' + + return ( +
+
+
+ +
+
+ + + + + +
+

+ {message.content} +

+
+
+
+
+
+
+ ) +} + function ToolRequestMessage({ message }: { message: SessionWithMessages['messages'][0] }) { const [isOpen, setIsOpen] = useState(false)