ask-rails 0.3.0 → 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: 958cbbe10b9ff728c6b73776250c422f0218a42d44a9f2608b95c1cd67e9d5a6
4
- data.tar.gz: db0a490be3201f02c9f71115bea770368ed37f9ce9fe409580c341a92d9dc3bc
3
+ metadata.gz: 862c3a8f40c0b2a6d720ab247ce87caecdfc9e5c9242c1daa386eb09d23fe300
4
+ data.tar.gz: 942cfdfd66cbc0e36ed56e1bfd16009226d12244cfa558e75facc99ace1c3781
5
5
  SHA512:
6
- metadata.gz: 1ab77385fc998e0d433d9875ad791ead151f18a085d2a067e69fca310d0eba999167f3482da09db1a38271a0e58e9235a60e958b414abd7740d026ed4d1c7fd7
7
- data.tar.gz: 31efdd5a7943822f2a0bc4aae038a1834f9933ecce4664928a11993f2441f30cfa0612dda2edc8729c616106549d13f2b9ea9bdb66757077833dc92b4aa121ad
6
+ metadata.gz: 0e769e86dec70f2a4533f0de007c00e32f63c8d270d2929b29e503c22fa5b4bf780c2464be5b65d30bbb0596a2967d075cf4b122d162169178acc62dece6bf54
7
+ data.tar.gz: eccd169faf6f16f3e949fe2562e07f46eeaa99d4118bb4c7952526daeeacc6d9337c804640087d1850a6d0ee34efd79aa05e7060889fec2d7b455a62a296b0e5
data/CHANGELOG.md CHANGED
@@ -1,3 +1,121 @@
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
+
1
119
  ## [0.3.0] — 2026-07-21
2
120
 
3
121
  ### Added
@@ -61,3 +179,14 @@
61
179
  - Rails Tools — `ReadFile`, `RunCommand`, `SearchCodebase`, `ReadRoutes` (Rails.root-aware)
62
180
  - Generators — `rails generate ask_rails:install` creates migration, initializer, `app/tools/`
63
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.
@@ -8,12 +8,13 @@ module Ask
8
8
  layout "ask/rails/application"
9
9
 
10
10
  before_action :authenticate!, unless: -> { Ask::Rails::Auth.check.nil? }
11
- before_action :set_session, only: [:message, :stream, :history, :show]
11
+ before_action :set_session, only: [:message, :stream, :history, :show, :audit]
12
12
 
13
13
  # GET /ask — main chat interface
14
14
  def index
15
15
  @sessions = load_sessions
16
16
  @active_session = @sessions.first
17
+ @audit_logs = load_recent_audit_logs
17
18
  render :index
18
19
  end
19
20
 
@@ -22,10 +23,8 @@ module Ask
22
23
  session_id = SecureRandom.uuid
23
24
 
24
25
  agent = build_agent_session
25
- agent.run("Hello") # warm up
26
26
  agent.save
27
27
 
28
- # Store minimal session metadata
29
28
  store_session_metadata(session_id, agent)
30
29
 
31
30
  redirect_to ask_rails.root_path
@@ -34,7 +33,8 @@ module Ask
34
33
  # GET /ask/sessions/:id — show a specific session
35
34
  def show
36
35
  @messages = load_session_messages(@session_id)
37
- render json: { id: @session_id, messages: @messages }
36
+ @audit_logs = load_session_audit_logs(@session_id)
37
+ render json: { id: @session_id, messages: @messages, audit_logs: @audit_logs }
38
38
  end
39
39
 
40
40
  # GET /ask/sessions — list all sessions
@@ -51,28 +51,41 @@ module Ask
51
51
  response.headers["Content-Type"] = "text/event-stream"
52
52
  response.headers["Cache-Control"] = "no-cache"
53
53
  response.headers["X-Accel-Buffering"] = "no"
54
-
55
- # Disable buffering for streaming
56
54
  response.headers["Last-Modified"] = Time.now.httpdate
57
55
 
58
56
  self.response_body = Enumerator.new do |yielder|
59
57
  agent = resume_or_create_session(@session_id)
60
58
 
59
+ # Subscribe to tool execution events
60
+ tool_sub = subscribe_tool_events(agent, yielder)
61
+
61
62
  yielder << "data: #{JSON.generate(type: 'start', session_id: @session_id)}\n\n"
62
63
 
63
- agent.run(prompt) do |chunk|
64
- if chunk.content&.length&.> 0
65
- yielder << "data: #{JSON.generate(type: 'delta', content: chunk.content)}\n\n"
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
66
72
  end
67
- if chunk.thinking&.length&.> 0
68
- yielder << "data: #{JSON.generate(type: 'thinking', content: chunk.thinking)}\n\n"
69
- end
70
- end
71
73
 
72
- agent.save
73
- @session = agent
74
-
75
- yielder << "data: #{JSON.generate(type: 'done', session_id: @session_id)}\n\n"
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
76
89
  end
77
90
  end
78
91
 
@@ -84,9 +97,9 @@ module Ask
84
97
 
85
98
  self.response_body = Enumerator.new do |yielder|
86
99
  messages = load_session_messages(@session_id)
87
- yielder << "data: #{JSON.generate(type: 'history', messages: messages)}\n\n"
100
+ audit_logs = load_session_audit_logs(@session_id)
101
+ yielder << "data: #{JSON.generate(type: 'history', messages: messages, audit_logs: audit_logs)}\n\n"
88
102
 
89
- # Keep connection open briefly for potential updates
90
103
  sleep 30
91
104
  yielder << "data: #{JSON.generate(type: 'keepalive')}\n\n"
92
105
  end
@@ -98,8 +111,14 @@ module Ask
98
111
  render json: messages
99
112
  end
100
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
+
101
120
  # DELETE /ask/sessions — destroy all sessions
102
- def destroy
121
+ def destroy_all
103
122
  persistence = Ask::Rails.configuration.persistence_adapter
104
123
  if persistence
105
124
  persistence.list.each { |id| persistence.delete(id) }
@@ -107,6 +126,13 @@ module Ask
107
126
  redirect_to ask_rails.root_path
108
127
  end
109
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
+
110
136
  private
111
137
 
112
138
  def authenticate!
@@ -122,6 +148,46 @@ module Ask
122
148
  Ask::Rails.agent_session(**extra)
123
149
  end
124
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
+
125
191
  def resume_or_create_session(session_id)
126
192
  persistence = Ask::Rails.configuration.persistence_adapter
127
193
 
@@ -144,7 +210,8 @@ module Ask
144
210
 
145
211
  persistence.list.map do |id|
146
212
  data = persistence.load(id) || {}
147
- { id: id, created_at: data.dig(:metadata, :created_at), message_count: (data[:messages] || []).length }
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) }
148
215
  end.sort_by { |s| s[:created_at] || "" }.reverse
149
216
  end
150
217
 
@@ -156,10 +223,62 @@ module Ask
156
223
  return [] unless data
157
224
 
158
225
  (data[:messages] || []).map { |m|
159
- { role: m[:role], content: m[:content].to_s.truncate(200) }
226
+ { role: m[:role], content: m[:content].to_s }
160
227
  }
161
228
  end
162
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
+
163
282
  def store_session_metadata(session_id, agent)
164
283
  persistence = Ask::Rails.configuration.persistence_adapter
165
284
  return unless persistence
@@ -180,6 +299,10 @@ module Ask
180
299
  def ask_rails_model
181
300
  Ask::Rails.configuration.default_model
182
301
  end
302
+
303
+ def tool_events_supported?
304
+ defined?(Ask::Agent::Events::ToolExecutionStart)
305
+ end
183
306
  end
184
307
  end
185
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