ask-rails 0.2.5 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6e0eb7fb3f6797eb16e096b2f2e5b0c79eb002d697be20df6ac0b2bbe5abf3a
4
- data.tar.gz: 4c6ffe88deca00e9e43472e286fb37630ba30953a5488d577a37ee805ccd7b73
3
+ metadata.gz: 862c3a8f40c0b2a6d720ab247ce87caecdfc9e5c9242c1daa386eb09d23fe300
4
+ data.tar.gz: 942cfdfd66cbc0e36ed56e1bfd16009226d12244cfa558e75facc99ace1c3781
5
5
  SHA512:
6
- metadata.gz: 3aafbab19ef97841a392242282f19886624412ec92602dfc525dcfd2743f05adc523f2604c139e847a9078ec307f596c85005796d8dab484ca32bb84bcd36e3e
7
- data.tar.gz: 0f13102f41d739aa2215684508a8d705df6ef2ad842b2404d4232bf4b689840ec0eca0153ea71f503ce114eadb11bd0396bee6d3d30202e9376caf04ebd506bc
6
+ metadata.gz: 0e769e86dec70f2a4533f0de007c00e32f63c8d270d2929b29e503c22fa5b4bf780c2464be5b65d30bbb0596a2967d075cf4b122d162169178acc62dece6bf54
7
+ data.tar.gz: eccd169faf6f16f3e949fe2562e07f46eeaa99d4118bb4c7952526daeeacc6d9337c804640087d1850a6d0ee34efd79aa05e7060889fec2d7b455a62a296b0e5
data/CHANGELOG.md CHANGED
@@ -1,3 +1,149 @@
1
+ ## [0.10.0] — 2026-07-23
2
+
3
+ ### Added
4
+
5
+ - **Session cleanup** — Auto-prune old or excess sessions via `max_session_age` and `max_sessions` config options. Prunes both `ask_sessions` and `ask_audit_logs` tables.
6
+ - `config.max_session_age = 7.days` — deletes sessions older than the threshold
7
+ - `config.max_sessions = 1000` — keeps at most N sessions, deletes oldest
8
+ - Auto-prunes on every `agent_session` call when configured
9
+ - Also callable manually: `Ask::Rails.cleanup!`
10
+ - Rake task: `rails ask_rails:cleanup`
11
+
12
+ ## [0.9.0] — 2026-07-23
13
+
14
+ ### Fixed
15
+
16
+ - **ReadFile, ReadRoutes, SearchCodebase** — Fixed `ArgumentError: unknown keyword: :data` crash on Ruby 4.0. Tools now return plain Hashes on success instead of using the incompatible `Ask::Result.success` API.
17
+ - **SearchCodebase** — Marked `path` param as optional (was incorrectly required). Added shell escaping for grep patterns.
18
+
19
+ ### Added
20
+
21
+ - **23 new execution tests** — ReadFile (3), SearchCodebase (3), ReadRoutes (2), ReadModel (6), plus 6 integration tests. Each tool now has real execution tests beyond just param checks.
22
+ - **160 total tests** — 0 failures, 0 errors.
23
+
24
+ ## [0.8.0] — 2026-07-23
25
+
26
+ ### Added
27
+
28
+ - **SchemaGraph tool** — Full application schema introspection in one call. Returns every model, table, column (with types and nullability), association (belongs_to, has_many, has_one, HABTM, through), validation, index, and polymorphic relationship. The agent gets a complete mental model of the app's data layer — no more reading models one at a time.
29
+ - `schema_graph(detail: "all")` — everything
30
+ - `schema_graph(detail: "models")` — just models and columns
31
+ - `schema_graph(detail: "associations")` — just the association graph
32
+ - `schema_graph(detail: "tables")` — just tables, columns, and indexes
33
+
34
+ - **RouteInspector tool** — Parsed Rails route table (not raw routes.rb). Returns every route with HTTP verb, path, controller, action, and name. Supports filtering by controller and path pattern. Replaces the old ReadRoutes which returned raw file content.
35
+ - `route_inspector` — all routes
36
+ - `route_inspector(controller: "users")` — routes for a specific controller
37
+ - `route_inspector(pattern: "admin")` — routes matching a path pattern
38
+
39
+ - **Core tools auto-discovery** — The 9 built-in Rails tools (ReadFile, RunCommand, SearchCodebase, ReadRoutes, QueryDatabase, ReadModel, ReadLog, SchemaGraph, RouteInspector) are now automatically included in every agent session. Previously they were loaded but not registered.
40
+
41
+ ### Changed
42
+
43
+ - `discover_tools!` now includes `CORE_RAILS_TOOLS` — all 9 Rails tools are automatically available to the agent.
44
+
45
+ ## [0.7.0] — 2026-07-23
46
+
47
+ ### Added
48
+
49
+ - **Tool execution visualization** — The chat UI now shows live tool execution cards as the agent runs. Each tool call displays as an expandable card with name, args, and real-time elapsed time. Cards show ✓ for success or ✗ for failure with duration.
50
+ - **Activity panel** — New "Activity" sidebar tab shows the audit log in real-time. Browse recent tool calls across all sessions with status, duration, and timestamp.
51
+ - **Per-session audit view** — Clicking a session loads its audit trail. New `GET /ask/sessions/:session_id/audit` endpoint.
52
+ - **Individual session deletion** — Delete sessions one at a time via the ✕ button in the sidebar. New `DELETE /ask/sessions/:id` route.
53
+ - **Sidebar search** — Filter sessions by text in the sidebar search box.
54
+ - **Session previews** — Sidebar shows the first message as a preview instead of just the session ID.
55
+ - **Keyboard shortcuts** — Ctrl+K / Cmd+K focuses the input. Enter sends (Shift+Enter for newline).
56
+ - **Environment badge** — Current Rails.env shown in both the sidebar footer and chat header.
57
+ - **`destroy_session` action** — New controller action for deleting individual sessions.
58
+
59
+ ### Changed
60
+
61
+ - **Chat layout** — Complete visual overhaul: darker theme, improved spacing, tool cards, sidebar tabs (Sessions/Activity), session search, and streaming indicator.
62
+ - **`message` SSE stream** — Now emits `tool_start`, `tool_end`, and `tool_update` events alongside `delta` and `thinking` for real-time tool execution visibility.
63
+ - **Controller actions** — `destroy` renamed to `destroy_all` for clarity.
64
+
65
+ ## [0.6.0] — 2026-07-23
66
+
67
+ ### Added
68
+
69
+ - **Per-environment permissions** — Configure access modes and command allowlists per Rails environment:
70
+ ```ruby
71
+ Ask::Rails.configure do |config|
72
+ config.environment :production do |env|
73
+ env.mode = :read_only
74
+ env.allowed_commands = [/^rails routes/]
75
+ env.denied_commands = [/rm/, /dropdb/]
76
+ end
77
+
78
+ config.environment :development do |env|
79
+ env.mode = :full_access
80
+ end
81
+ end
82
+ ```
83
+ - **`Ask::Rails::EnvironmentPermissions`** — New config class holding `mode`, `allowed_commands`, and `denied_commands` per environment.
84
+ - **Automatic Permissions wiring** — When an environment `mode` is set, `agent_session` automatically creates an `Ask::Agent::Extensions::Permissions` extension and wires it into the session hooks.
85
+ - **Effective rule resolution** — `Configuration#effective_allowed_commands`, `#effective_denied_commands`, and `#effective_mode` resolve per-environment rules or fall back to global config.
86
+
87
+ ## [0.5.0] — 2026-07-22
88
+
89
+ ### Added
90
+
91
+ - **Command Allowlist** — `RunCommand` now checks `allowed_commands` and `denied_commands` before executing. Configure with regex patterns:
92
+ - `Ask::Rails.configuration.allowed_commands = [/^rails /, /^git status/]`
93
+ - `Ask::Rails.configuration.denied_commands = [/rm /, /dropdb/]`
94
+ - `denied_commands` takes precedence over `allowed_commands`
95
+ - When `allowed_commands` is nil (default), all commands pass through (except those in `denied_commands`)
96
+ - Blocked commands return `Ask::Result.error` with a descriptive message and are recorded in the audit log
97
+
98
+ - **`allowed_commands` and `denied_commands` configuration options** — Two new arrays of regex patterns on the `Configuration` object.
99
+
100
+ ## [0.4.0] — 2026-07-22
101
+
102
+ ### Added
103
+
104
+ - **Audit Log** — Every tool call is now recorded in the `ask_audit_logs` table with the intent (sanitized params), outcome (status/timing), and user context. Append-only, never modified. Provides a trustworthy record of what the agent did without storing sensitive data or full results.
105
+ - `Ask::Rails::AuditLog.log` — logs a tool execution event
106
+ - Sensitive params (keys matching `password`, `secret`, `token`, `api_key`, `key`) are automatically redacted as `[REDACTED]`
107
+ - Fires `audit_log.ask_rails` ActiveSupport notification for host app alerting
108
+ - Results are summarized (row count for queries, exit status for commands, etc.) — full data never stored
109
+ - Generator creates the `create_ask_audit_logs` migration
110
+
111
+ - **`current_user` configuration** — `Ask::Rails.configure { |c| c.current_user = -> { Current.user ? { id: Current.user.id, email: Current.user.email } : nil } }` attaches user context to every audit log entry.
112
+
113
+ - **`Ask::Rails::Tool.session_id`** — Thread-local accessor so tool calls are correlated with their agent session in the audit log.
114
+
115
+ ### Changed
116
+
117
+ - **Tool base class** — `Ask::Rails::Tool#call` is now instrumented to automatically log every invocation to the audit log. All 7 Rails tools inherit this behavior.
118
+
119
+ ## [0.3.0] — 2026-07-21
120
+
121
+ ### Added
122
+
123
+ - **Admin chat UI** — Mount `Ask::Rails::Engine` at `/ask` and get a working chat interface with SSE streaming. No generator needed — just mount and go.
124
+
125
+ ```
126
+ GET /ask → Chat UI
127
+ POST /ask/sessions → Create new session
128
+ POST /ask/sessions/:id/messages → Send message (SSE streamed)
129
+ GET /ask/sessions/:id/messages → Message history
130
+ GET /ask/sessions/:id/stream → SSE stream for existing session
131
+ ```
132
+
133
+ - **`Ask::Rails::ChatController`** — Full controller with actions: `index`, `create`, `message` (SSE streaming), `stream`, `history`, `destroy`. Supports real-time streaming via `Enumerator` + `text/event-stream`.
134
+
135
+ - **Chat layout and view** — A dark-themed admin chat interface shipped in the gem (not generated). Includes sidebar with session list, message history, SSE streaming, and keyboard navigation.
136
+
137
+ - **`Ask::Rails::Auth`** — Configurable authentication hook. Set `Ask::Rails::Auth.check = -> { ... }` to protect the chat behind your existing auth system. Runs in controller context — `current_user`, `redirect_to`, etc. are available.
138
+
139
+ - **Engine routes** — Routes defined in `config/routes.rb` with proper `Ask::Rails::Engine.routes.draw` isolation.
140
+
141
+ ### Changed
142
+
143
+ - **README** — Rewritten with clear positioning: ask-rails is for internal admin agents. ask-agent is for external-facing agents. Includes comparison table, quick start guide, and tool reference.
144
+ - **Engine** — `isolate_namespace Ask::Rails` for proper route isolation.
145
+ - **Gemspec** — Now includes `app/` and `config/` directories for engine views, controllers, and routes.
146
+
1
147
  ## [0.2.5] - 2026-06-25
2
148
 
3
149
  ### Changed
@@ -33,3 +179,14 @@
33
179
  - Rails Tools — `ReadFile`, `RunCommand`, `SearchCodebase`, `ReadRoutes` (Rails.root-aware)
34
180
  - Generators — `rails generate ask_rails:install` creates migration, initializer, `app/tools/`
35
181
  - Dependencies: rails >= 7.1, ask-tools, ask-tools-shell, ask-agent, ask-auth
182
+
183
+ ## [0.11.0] — 2026-07-23
184
+
185
+ ### Changed
186
+
187
+ - **`Persistence` adapter now speaks `State::Adapter` contract** — Added `set(key, value)` and `get(key)` methods delegating to `save`/`load`. This makes it compatible with the new `state:` keyword on `Ask::Agent::Session`. Old `save`/`load` interface preserved for backward compatibility.
188
+ - **`agent_session` uses `state:` keyword** — Changed from `persistence:` to `state:`.
189
+
190
+ ### Added
191
+
192
+ - **Persistence tests** — New `FakeModel` fixture with 6 real roundtrip tests covering set/get, missing key, delete, list, and old save/load interface.
data/README.md CHANGED
@@ -1,8 +1,22 @@
1
1
  # ask-rails
2
2
 
3
- Rails integration for the ask-rb ecosystem. The only gem a Rails app needs to join
4
- the ask-rb stack — provides a Railtie, AR session persistence, a session factory,
5
- automatic service gem discovery, and generators.
3
+ An admin AI agent for your Rails app. Mount the engine, get a chat interface at `/ask` that can inspect your code, query your database, read logs, and help you debug — all through an authenticated admin UI.
4
+
5
+ ## Who is this for?
6
+
7
+ - **Rails developers** who want an AI co-pilot that understands their app's codebase, schema, routes, and logs
8
+ - **Internal/Admin use only** — the agent has direct access to your database, file system, and shell. Not for external/customer-facing use.
9
+
10
+ For building customer-facing AI agents, use `ask-agent` directly with your own tools and UI.
11
+
12
+ ## What it gives you
13
+
14
+ - **7 Rails-aware tools**: `ReadFile`, `QueryDatabase`, `ReadRoutes`, `ReadModel`, `ReadLog`, `RunCommand`, `SearchCodebase`
15
+ - **Admin chat UI**: Mount the engine, get a working chat at `/ask` with SSE streaming
16
+ - **Auth integration**: Protect `/ask` behind your existing Devise/authentication
17
+ - **AR persistence**: Agent sessions survive server restarts
18
+ - **Service discovery**: Auto-detects installed ask-* service gems
19
+ - **Skills**: Built-in guides for Rails debugging, deployment, and database performance
6
20
 
7
21
  ## Installation
8
22
 
@@ -11,24 +25,94 @@ bundle add ask-rails
11
25
  rails generate ask_rails:install
12
26
  ```
13
27
 
28
+ ## Quick Start
29
+
30
+ Add the engine mount and auth protection to `config/routes.rb`:
31
+
32
+ ```ruby
33
+ # config/routes.rb
34
+ Rails.application.routes.draw do
35
+ # ... your routes ...
36
+
37
+ authenticate :user, ->(u) { u.admin? } do
38
+ mount Ask::Rails::Engine, at: "/ask"
39
+ end
40
+ end
41
+ ```
42
+
43
+ Then visit `/ask` in your browser.
44
+
14
45
  ## Usage
15
46
 
47
+ ### Configuration
48
+
16
49
  ```ruby
17
- # In any Rails context
50
+ # config/initializers/ask_rails.rb
51
+ Ask::Rails.configure do |c|
52
+ c.default_model = "claude-sonnet-4"
53
+ c.max_turns = 50
54
+ end
55
+ ```
56
+
57
+ ### Programmatic Access
58
+
59
+ ```ruby
60
+ # From any controller, view, or job
18
61
  session = Ask::Rails.agent_session
19
62
  session.run("Find all open issues labeled 'bug' in our repo")
20
63
  ```
21
64
 
22
- Service gems like `ask-github`, `ask-slack` are auto-discovered — the agent gets their
23
- context (auth info, quick-start snippets, error guides) in the system prompt automatically.
65
+ ### Route Helpers
24
66
 
25
- ## Development
67
+ ```ruby
68
+ ask_rails.root_path # => /ask
69
+ ask_rails.sessions_path # => /ask/sessions
70
+ ask_rails.session_messages_path(session_id) # => /ask/sessions/:id/messages
71
+ ```
26
72
 
27
- ```bash
28
- bin/setup
29
- bundle exec rake test
73
+ ### Auth
74
+
75
+ By default, the admin chat is unprotected. Add auth in your routes (as shown above) or set a custom check:
76
+
77
+ ```ruby
78
+ # config/initializers/ask_rails.rb
79
+ Ask::Rails::Auth.check = -> {
80
+ redirect_to main_app.login_path unless current_user&.admin?
81
+ }
30
82
  ```
31
83
 
84
+ ## Tools
85
+
86
+ | Tool | What it does |
87
+ |---|---|
88
+ | `ReadFile` | Read any file (relative to `Rails.root`) |
89
+ | `QueryDatabase` | Run read-only SQL (rejects non-SELECT in production) |
90
+ | `ReadModel` | Inspect AR model schema, associations, validations |
91
+ | `ReadRoutes` | View `config/routes.rb` |
92
+ | `ReadLog` | Read log files with level/search filtering |
93
+ | `RunCommand` | Run shell commands in the app root |
94
+ | `SearchCodebase` | Grep the codebase for patterns |
95
+
96
+ ## Engine Routes
97
+
98
+ ```
99
+ GET /ask → Chat UI
100
+ POST /ask/sessions → Create new session
101
+ POST /ask/sessions/:id/messages → Send message (SSE streamed response)
102
+ GET /ask/sessions/:id/messages → Get message history
103
+ GET /ask/sessions/:id/stream → SSE stream for existing session
104
+ ```
105
+
106
+ ## Compared to ask-agent
107
+
108
+ | `ask-agent` | `ask-rails` |
109
+ |---|---|
110
+ | Build external-facing agents | Build an internal admin co-pilot |
111
+ | Bring your own tools | Ships Rails-specific tools |
112
+ | Bring your own UI | Ships an admin chat UI |
113
+ | Any Ruby app | Rails apps only |
114
+ | General purpose | Development, debugging, ops |
115
+
32
116
  ## License
33
117
 
34
118
  MIT
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ask
6
+ module Rails
7
+ class ChatController < ::ActionController::Base
8
+ layout "ask/rails/application"
9
+
10
+ before_action :authenticate!, unless: -> { Ask::Rails::Auth.check.nil? }
11
+ before_action :set_session, only: [:message, :stream, :history, :show, :audit]
12
+
13
+ # GET /ask — main chat interface
14
+ def index
15
+ @sessions = load_sessions
16
+ @active_session = @sessions.first
17
+ @audit_logs = load_recent_audit_logs
18
+ render :index
19
+ end
20
+
21
+ # POST /ask/sessions — create a new session
22
+ def create
23
+ session_id = SecureRandom.uuid
24
+
25
+ agent = build_agent_session
26
+ agent.save
27
+
28
+ store_session_metadata(session_id, agent)
29
+
30
+ redirect_to ask_rails.root_path
31
+ end
32
+
33
+ # GET /ask/sessions/:id — show a specific session
34
+ def show
35
+ @messages = load_session_messages(@session_id)
36
+ @audit_logs = load_session_audit_logs(@session_id)
37
+ render json: { id: @session_id, messages: @messages, audit_logs: @audit_logs }
38
+ end
39
+
40
+ # GET /ask/sessions — list all sessions
41
+ def index_sessions
42
+ @sessions = load_sessions
43
+ render json: @sessions
44
+ end
45
+
46
+ # POST /ask/sessions/:session_id/messages — send a message, get SSE stream
47
+ def message
48
+ prompt = params[:message].to_s.strip
49
+ return head :bad_request if prompt.empty?
50
+
51
+ response.headers["Content-Type"] = "text/event-stream"
52
+ response.headers["Cache-Control"] = "no-cache"
53
+ response.headers["X-Accel-Buffering"] = "no"
54
+ response.headers["Last-Modified"] = Time.now.httpdate
55
+
56
+ self.response_body = Enumerator.new do |yielder|
57
+ agent = resume_or_create_session(@session_id)
58
+
59
+ # Subscribe to tool execution events
60
+ tool_sub = subscribe_tool_events(agent, yielder)
61
+
62
+ yielder << "data: #{JSON.generate(type: 'start', session_id: @session_id)}\n\n"
63
+
64
+ begin
65
+ agent.run(prompt) do |chunk|
66
+ if chunk.content&.length&.> 0
67
+ yielder << "data: #{JSON.generate(type: 'delta', content: chunk.content)}\n\n"
68
+ end
69
+ if chunk.thinking&.length&.> 0
70
+ yielder << "data: #{JSON.generate(type: 'thinking', content: chunk.thinking)}\n\n"
71
+ end
72
+ end
73
+
74
+ agent.save
75
+ @session = agent
76
+
77
+ yielder << "data: #{JSON.generate(type: 'done', session_id: @session_id)}\n\n"
78
+ rescue Ask::Agent::MaxTurnsExceeded => e
79
+ yielder << "data: #{JSON.generate(type: 'error', message: "Agent hit the turn limit (#{e.message})")}\n\n"
80
+ rescue Ask::Auth::MissingCredential => e
81
+ yielder << "data: #{JSON.generate(type: 'error', message: "Missing API key: #{e.message}. Check your provider configuration.")}\n\n"
82
+ rescue Ask::Auth::InvalidCredential => e
83
+ yielder << "data: #{JSON.generate(type: 'error', message: "Invalid API key: #{e.message}. Update your credentials.")}\n\n"
84
+ rescue StandardError => e
85
+ yielder << "data: #{JSON.generate(type: 'error', message: "Agent error: #{e.message}")}\n\n"
86
+ end
87
+ ensure
88
+ agent&.remove_event_subscriber(tool_sub) if tool_sub
89
+ end
90
+ end
91
+
92
+ # GET /ask/sessions/:session_id/stream — SSE stream for an existing session
93
+ def stream
94
+ response.headers["Content-Type"] = "text/event-stream"
95
+ response.headers["Cache-Control"] = "no-cache"
96
+ response.headers["X-Accel-Buffering"] = "no"
97
+
98
+ self.response_body = Enumerator.new do |yielder|
99
+ messages = load_session_messages(@session_id)
100
+ audit_logs = load_session_audit_logs(@session_id)
101
+ yielder << "data: #{JSON.generate(type: 'history', messages: messages, audit_logs: audit_logs)}\n\n"
102
+
103
+ sleep 30
104
+ yielder << "data: #{JSON.generate(type: 'keepalive')}\n\n"
105
+ end
106
+ end
107
+
108
+ # GET /ask/sessions/:session_id/messages — get message history as JSON
109
+ def history
110
+ messages = load_session_messages(@session_id)
111
+ render json: messages
112
+ end
113
+
114
+ # GET /ask/sessions/:session_id/audit — get audit logs for a session
115
+ def audit
116
+ logs = load_session_audit_logs(@session_id)
117
+ render json: logs
118
+ end
119
+
120
+ # DELETE /ask/sessions — destroy all sessions
121
+ def destroy_all
122
+ persistence = Ask::Rails.configuration.persistence_adapter
123
+ if persistence
124
+ persistence.list.each { |id| persistence.delete(id) }
125
+ end
126
+ redirect_to ask_rails.root_path
127
+ end
128
+
129
+ # DELETE /ask/sessions/:session_id — destroy a specific session
130
+ def destroy_session
131
+ persistence = Ask::Rails.configuration.persistence_adapter
132
+ persistence&.delete(params[:session_id])
133
+ redirect_to ask_rails.root_path
134
+ end
135
+
136
+ private
137
+
138
+ def authenticate!
139
+ instance_eval(&Ask::Rails::Auth.check) if Ask::Rails::Auth.check
140
+ end
141
+
142
+ def set_session
143
+ @session_id = params[:session_id] || params[:id]
144
+ head :not_found unless @session_id
145
+ end
146
+
147
+ def build_agent_session(**extra)
148
+ Ask::Rails.agent_session(**extra)
149
+ end
150
+
151
+ def subscribe_tool_events(agent, yielder)
152
+ return nil unless agent.respond_to?(:on_event)
153
+
154
+ agent.on_event do |event|
155
+ case event
156
+ when Ask::Agent::Events::ToolExecutionStart
157
+ yielder << "data: #{JSON.generate(
158
+ type: 'tool_start',
159
+ name: event.name,
160
+ id: event.id,
161
+ args: safe_tool_args(event.arguments)
162
+ )}\n\n"
163
+ when Ask::Agent::Events::ToolExecutionEnd
164
+ yielder << "data: #{JSON.generate(
165
+ type: 'tool_end',
166
+ name: event.name,
167
+ id: event.id,
168
+ duration_ms: event.duration_ms,
169
+ is_error: event.is_error
170
+ )}\n\n"
171
+ when Ask::Agent::Events::ToolExecutionUpdate
172
+ yielder << "data: #{JSON.generate(
173
+ type: 'tool_update',
174
+ id: event.id,
175
+ content: event.partial_result.to_s.truncate(200)
176
+ )}\n\n"
177
+ end
178
+ end
179
+ end
180
+
181
+ def safe_tool_args(args)
182
+ return {} unless args.is_a?(Hash)
183
+
184
+ safe = args.dup
185
+ %w[password secret token api_key key auth_token access_token sql command].each do |sensitive|
186
+ safe[sensitive] = "[REDACTED]" if safe.key?(sensitive)
187
+ end
188
+ safe
189
+ end
190
+
191
+ def resume_or_create_session(session_id)
192
+ persistence = Ask::Rails.configuration.persistence_adapter
193
+
194
+ if persistence
195
+ data = persistence.load(session_id)
196
+ if data
197
+ session = Ask::Agent::Session.load(session_id, adapter: persistence)
198
+ return session if session
199
+ end
200
+ end
201
+
202
+ Ask::Rails.agent_session(persistence: persistence).tap do |s|
203
+ s.instance_variable_set(:@id, session_id)
204
+ end
205
+ end
206
+
207
+ def load_sessions
208
+ persistence = Ask::Rails.configuration.persistence_adapter
209
+ return [] unless persistence
210
+
211
+ persistence.list.map do |id|
212
+ data = persistence.load(id) || {}
213
+ messages = data[:messages] || []
214
+ { id: id, created_at: data.dig(:metadata, :created_at), message_count: messages.length, preview: messages.first&.dig(:content).to_s.truncate(60) }
215
+ end.sort_by { |s| s[:created_at] || "" }.reverse
216
+ end
217
+
218
+ def load_session_messages(session_id)
219
+ persistence = Ask::Rails.configuration.persistence_adapter
220
+ return [] unless persistence
221
+
222
+ data = persistence.load(session_id)
223
+ return [] unless data
224
+
225
+ (data[:messages] || []).map { |m|
226
+ { role: m[:role], content: m[:content].to_s }
227
+ }
228
+ end
229
+
230
+ def load_session_audit_logs(session_id)
231
+ return [] unless defined?(ActiveRecord::Base)
232
+
233
+ ActiveRecord::Base.connection.execute(
234
+ "SELECT tool_name, status, duration_ms, environment, recorded_at, " \
235
+ "params, result_summary, error_message, user_context " \
236
+ "FROM ask_audit_logs WHERE session_id = #{ActiveRecord::Base.connection.quote(session_id.to_s)} " \
237
+ "ORDER BY recorded_at ASC"
238
+ ).map do |row|
239
+ {
240
+ tool_name: row["tool_name"],
241
+ status: row["status"],
242
+ duration_ms: row["duration_ms"],
243
+ environment: row["environment"],
244
+ recorded_at: row["recorded_at"],
245
+ params: parse_json_field(row["params"]),
246
+ result_summary: parse_json_field(row["result_summary"]),
247
+ error_message: row["error_message"],
248
+ user_context: parse_json_field(row["user_context"])
249
+ }
250
+ end
251
+ rescue StandardError
252
+ []
253
+ end
254
+
255
+ def load_recent_audit_logs
256
+ return [] unless defined?(ActiveRecord::Base)
257
+
258
+ ActiveRecord::Base.connection.execute(
259
+ "SELECT session_id, tool_name, status, duration_ms, recorded_at " \
260
+ "FROM ask_audit_logs ORDER BY recorded_at DESC LIMIT 100"
261
+ ).map do |row|
262
+ {
263
+ session_id: row["session_id"].to_s[0..7],
264
+ tool_name: row["tool_name"],
265
+ status: row["status"],
266
+ duration_ms: row["duration_ms"],
267
+ recorded_at: row["recorded_at"]
268
+ }
269
+ end
270
+ rescue StandardError
271
+ []
272
+ end
273
+
274
+ def parse_json_field(value)
275
+ return nil unless value
276
+ return value if value.is_a?(Hash) || value.is_a?(Array)
277
+ JSON.parse(value)
278
+ rescue JSON::ParserError
279
+ value
280
+ end
281
+
282
+ def store_session_metadata(session_id, agent)
283
+ persistence = Ask::Rails.configuration.persistence_adapter
284
+ return unless persistence
285
+
286
+ persistence.save(session_id, {
287
+ id: session_id,
288
+ messages: agent.messages.map { |m|
289
+ { role: m.role, content: m.content.to_s }
290
+ },
291
+ metadata: {
292
+ model: ask_rails_model,
293
+ created_at: Time.now.iso8601,
294
+ updated_at: Time.now.iso8601
295
+ }
296
+ })
297
+ end
298
+
299
+ def ask_rails_model
300
+ Ask::Rails.configuration.default_model
301
+ end
302
+
303
+ def tool_events_supported?
304
+ defined?(Ask::Agent::Events::ToolExecutionStart)
305
+ end
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Rails
5
+ # ActiveRecord model for persisting agent sessions.
6
+ #
7
+ # Created by the +ask_rails:install+ generator. The +ask_sessions+ table
8
+ # stores session state in a JSONB +data+ column, keyed by a unique
9
+ # +session_id+ string.
10
+ #
11
+ # Used automatically when +persistence_adapter+ is configured with the
12
+ # default model class:
13
+ #
14
+ # Ask::Rails.configure do |config|
15
+ # config.persistence_adapter = Ask::Rails::Persistence.new
16
+ # end
17
+ #
18
+ # Without +model_class:+, +Persistence+ defaults to +Ask::Rails::Session+.
19
+ class Session < ActiveRecord::Base
20
+ self.table_name = "ask_sessions"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,6 @@
1
+ <!-- Chat interface rendered by the layout -->
2
+ <!-- The layout handles the full UI. This view is minimal -->
3
+ <!-- since the layout and JavaScript drive the interface. -->
4
+
5
+ <%= tag.div id: "ask-rails-data",
6
+ data: { sessions: @sessions.to_json, active_session_id: @active_session&.dig(:id) } %>