rails_console_ai 0.36.0 → 0.38.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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +49 -11
- data/app/controllers/rails_console_ai/artifacts_controller.rb +67 -0
- data/app/models/rails_console_ai/artifact.rb +231 -0
- data/app/views/layouts/rails_console_ai/application.html.erb +1 -0
- data/app/views/rails_console_ai/artifacts/index.html.erb +60 -0
- data/app/views/rails_console_ai/artifacts/show.html.erb +119 -0
- data/app/views/rails_console_ai/sessions/show.html.erb +6 -0
- data/config/routes.rb +8 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +5 -0
- data/lib/rails_console_ai/channel/console.rb +12 -3
- data/lib/rails_console_ai/channel/slack.rb +1 -1
- data/lib/rails_console_ai/configuration.rb +72 -12
- data/lib/rails_console_ai/context_builder.rb +48 -18
- data/lib/rails_console_ai/conversation_engine.rb +97 -27
- data/lib/rails_console_ai/providers/anthropic.rb +3 -1
- data/lib/rails_console_ai/providers/base.rb +20 -0
- data/lib/rails_console_ai/providers/bedrock.rb +5 -2
- data/lib/rails_console_ai/providers/local.rb +1 -1
- data/lib/rails_console_ai/providers/openai.rb +1 -1
- data/lib/rails_console_ai/providers/openrouter.rb +9 -1
- data/lib/rails_console_ai/slack_bot.rb +40 -12
- data/lib/rails_console_ai/slash_commands.rb +1 -0
- data/lib/rails_console_ai/sub_agent.rb +28 -0
- data/lib/rails_console_ai/tools/registry.rb +231 -1
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +222 -0
- metadata +5 -1
|
@@ -32,6 +32,10 @@ module RailsConsoleAi
|
|
|
32
32
|
# OpenRouter only returns usage.cost — the real dollars every cost
|
|
33
33
|
# readout prefers over an estimate — when the request asks for it.
|
|
34
34
|
body[:usage] = { include: true }
|
|
35
|
+
# OpenRouter maps `verbosity` to Anthropic's output_config.effort; its
|
|
36
|
+
# `reasoning.effort` sets a thinking-token budget instead.
|
|
37
|
+
effort = config.resolved_effort
|
|
38
|
+
body[:verbosity] = effort if effort
|
|
35
39
|
body
|
|
36
40
|
end
|
|
37
41
|
|
|
@@ -76,7 +80,11 @@ module RailsConsoleAi
|
|
|
76
80
|
result.input_tokens = [result.input_tokens - cache_read - cache_write, 0].max
|
|
77
81
|
end
|
|
78
82
|
|
|
79
|
-
|
|
83
|
+
# Some OpenRouter upstreams return tool calls with a finish_reason of
|
|
84
|
+
# "stop"; trust the calls over the label. Not when the response was cut
|
|
85
|
+
# off at the output cap, though — those arguments are half-written, and
|
|
86
|
+
# promoting them to :tool_use hands the engine a truncated call to run.
|
|
87
|
+
if result.tool_calls&.any? && !result.truncated?
|
|
80
88
|
result.stop_reason = :tool_use
|
|
81
89
|
end
|
|
82
90
|
|
|
@@ -453,7 +453,7 @@ module RailsConsoleAi
|
|
|
453
453
|
Thread.current.report_on_exception = false
|
|
454
454
|
Thread.current[:log_prefix] = "[#{channel_id}/#{thread_ts}]#{channel_log_tag(channel_id)} @#{user_name}"
|
|
455
455
|
begin
|
|
456
|
-
channel.display_status("
|
|
456
|
+
channel.display_status("_#{session_reference(engine, text, channel_id, thread_ts)}_")
|
|
457
457
|
if restored
|
|
458
458
|
puts "Restored session for thread #{thread_ts} (#{engine.history.length} messages)"
|
|
459
459
|
channel.display_status("_(session restored — continuing from previous conversation)_")
|
|
@@ -468,6 +468,32 @@ module RailsConsoleAi
|
|
|
468
468
|
end
|
|
469
469
|
end
|
|
470
470
|
|
|
471
|
+
# A link to the session's page, so whoever is in the thread can find the
|
|
472
|
+
# transcript later. Falls back to the channel/thread pair the logs are
|
|
473
|
+
# tagged with when there is no row to link to, or no host to link with.
|
|
474
|
+
def session_reference(engine, text, channel_id, thread_ts)
|
|
475
|
+
session_id = engine.ensure_session_record(text)
|
|
476
|
+
unless session_id
|
|
477
|
+
return session_reference_fallback(channel_id, thread_ts, "no session row (session_logging off, or the sessions table not found)")
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
url = RailsConsoleAi.session_url(session_id)
|
|
481
|
+
unless url.start_with?('http')
|
|
482
|
+
return session_reference_fallback(channel_id, thread_ts, "no host for #{url} (set config.base_url)")
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
"Session ##{session_id}: <#{url}|view transcript>"
|
|
486
|
+
rescue => e
|
|
487
|
+
session_reference_fallback(channel_id, thread_ts, "#{e.class}: #{e.message}")
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Said on stdout, where the bot's own log goes, so a missing link is explained.
|
|
491
|
+
# The session thread already prefixes the line with the channel and thread.
|
|
492
|
+
def session_reference_fallback(channel_id, thread_ts, reason)
|
|
493
|
+
puts "no session link: #{reason}"
|
|
494
|
+
"Session: #{channel_id}/#{thread_ts}"
|
|
495
|
+
end
|
|
496
|
+
|
|
471
497
|
def restore_from_db(engine, thread_ts)
|
|
472
498
|
require 'rails_console_ai/session_logger'
|
|
473
499
|
saved = SessionLogger.find_by_slack_thread(thread_ts)
|
|
@@ -652,14 +678,11 @@ module RailsConsoleAi
|
|
|
652
678
|
when 'model'
|
|
653
679
|
summary = bang_model(engine)
|
|
654
680
|
post_message(channel: channel_id, thread_ts: thread_ts, text: summary)
|
|
655
|
-
when 'think'
|
|
656
|
-
|
|
681
|
+
when 'think', 'unthink', 'cheap'
|
|
682
|
+
tier = { 'think' => :thinking, 'unthink' => :default, 'cheap' => :cheap }[cmd]
|
|
683
|
+
engine.switch_model_tier(tier)
|
|
657
684
|
post_message(channel: channel_id, thread_ts: thread_ts,
|
|
658
|
-
text: "Switched to
|
|
659
|
-
when 'unthink'
|
|
660
|
-
model = engine.downgrade_from_thinking_model
|
|
661
|
-
post_message(channel: channel_id, thread_ts: thread_ts,
|
|
662
|
-
text: "Switched to default model: `#{model}`")
|
|
685
|
+
text: "Switched to #{tier} model: `#{engine.effective_model_label}`")
|
|
663
686
|
when 'context'
|
|
664
687
|
summary = bang_context(engine)
|
|
665
688
|
post_message(channel: channel_id, thread_ts: thread_ts, text: summary)
|
|
@@ -678,7 +701,7 @@ module RailsConsoleAi
|
|
|
678
701
|
end
|
|
679
702
|
end
|
|
680
703
|
else
|
|
681
|
-
commands = %w[name usage cost model compact think unthink context retry].map { |c| "`!#{c}`" }
|
|
704
|
+
commands = %w[name usage cost model compact think unthink cheap context retry].map { |c| "`!#{c}`" }
|
|
682
705
|
post_message(channel: channel_id, thread_ts: thread_ts,
|
|
683
706
|
text: "Unknown command `!#{cmd}`. Available: #{commands.join(', ')}")
|
|
684
707
|
end
|
|
@@ -705,13 +728,18 @@ module RailsConsoleAi
|
|
|
705
728
|
def bang_model(engine = nil)
|
|
706
729
|
config = RailsConsoleAi.configuration
|
|
707
730
|
model = engine ? engine.effective_model : config.resolved_model
|
|
708
|
-
thinking = config.resolved_thinking_model
|
|
709
731
|
pricing = Configuration.pricing_for(model)
|
|
732
|
+
with_effort = lambda do |m, e|
|
|
733
|
+
e = Configuration.effort_for(m, e)
|
|
734
|
+
e ? "`#{m}` (#{e} effort)" : "`#{m}`"
|
|
735
|
+
end
|
|
710
736
|
|
|
711
737
|
lines = ["*Model info:*"]
|
|
712
738
|
lines << " Provider: `#{config.provider}`"
|
|
713
|
-
lines << " Model: `#{model}`"
|
|
714
|
-
lines << "
|
|
739
|
+
lines << (engine ? " Model: `#{engine.effective_model_label}` [#{engine.model_tier}]" : " Model: `#{model}`")
|
|
740
|
+
lines << " Default model: #{with_effort.(config.resolved_model, config.effort)}"
|
|
741
|
+
lines << " Thinking model: #{with_effort.(config.resolved_thinking_model, config.thinking_effort)}"
|
|
742
|
+
lines << " Cheap model: `#{config.resolved_cheap_model}`"
|
|
715
743
|
lines << " Max tokens: #{config.resolved_max_tokens}"
|
|
716
744
|
if pricing
|
|
717
745
|
lines << " Pricing: $#{pricing[:input] * 1_000_000}/M in, $#{pricing[:output] * 1_000_000}/M out"
|
|
@@ -28,6 +28,7 @@ module RailsConsoleAi
|
|
|
28
28
|
'model' => 'Show provider, model, and pricing info',
|
|
29
29
|
'think' => 'Switch to thinking model',
|
|
30
30
|
'unthink' => 'Switch back to default model',
|
|
31
|
+
'cheap' => 'Switch to the cheaper, faster model',
|
|
31
32
|
'compact' => 'Summarize conversation to reduce context',
|
|
32
33
|
'usage' => 'Show session token totals',
|
|
33
34
|
'cost' => 'Show cost estimate by model',
|
|
@@ -87,6 +87,15 @@ module RailsConsoleAi
|
|
|
87
87
|
@cost += result.cost || 0
|
|
88
88
|
|
|
89
89
|
break if channel.cancelled?
|
|
90
|
+
|
|
91
|
+
# Cut off at the output cap: the tool call it was writing is incomplete
|
|
92
|
+
# and gets discarded, so the sub-agent would otherwise return whatever
|
|
93
|
+
# half-sentence preceded it as if that were its finding.
|
|
94
|
+
if result.truncated?
|
|
95
|
+
channel.display_warning(" Sub-agent response hit the output-token cap — its result is incomplete.")
|
|
96
|
+
break
|
|
97
|
+
end
|
|
98
|
+
|
|
90
99
|
break unless result.tool_use?
|
|
91
100
|
|
|
92
101
|
# Display the LLM's reasoning text before executing its tool calls
|
|
@@ -202,6 +211,7 @@ module RailsConsoleAi
|
|
|
202
211
|
parts << base_instructions unless @agent_config['skip_base_instructions']
|
|
203
212
|
parts << guide_context
|
|
204
213
|
parts << pinned_memory_context
|
|
214
|
+
parts << memory_context if memory_recall_available?
|
|
205
215
|
parts << @agent_config['body'] if @agent_config['body'] && !@agent_config['body'].strip.empty?
|
|
206
216
|
parts.compact.join("\n\n")
|
|
207
217
|
end
|
|
@@ -217,6 +227,9 @@ module RailsConsoleAi
|
|
|
217
227
|
- Use tools to look up schema/model details rather than guessing.
|
|
218
228
|
- Prefer ActiveRecord query interface over raw SQL.
|
|
219
229
|
- Use describe_model to understand models before querying them.
|
|
230
|
+
- Check the Memories section below BEFORE investigating. If a memory looks relevant,
|
|
231
|
+
call recall_memory (by name) or recall_memories (by keyword) to read it, and apply it.
|
|
232
|
+
Memories record how this app actually works; do not rediscover what one already says.
|
|
220
233
|
- NEVER fabricate URLs, IDs, or data. Always look things up using model methods.
|
|
221
234
|
- When you find methods on a model (e.g. via .methods.grep), USE them rather than
|
|
222
235
|
constructing values manually.
|
|
@@ -235,6 +248,21 @@ module RailsConsoleAi
|
|
|
235
248
|
nil
|
|
236
249
|
end
|
|
237
250
|
|
|
251
|
+
# Same memory index the main agent sees, so the sub-agent knows what it can
|
|
252
|
+
# recall instead of finding out only if it happens to call recall_memories.
|
|
253
|
+
def memory_context
|
|
254
|
+
require 'rails_console_ai/context_builder'
|
|
255
|
+
ContextBuilder.new(RailsConsoleAi.configuration).memory_context
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# An agent definition can restrict tools; the output-explorer does, to
|
|
259
|
+
# execute_code alone. Listing memories it cannot recall would only invite
|
|
260
|
+
# calls to a tool it does not have.
|
|
261
|
+
def memory_recall_available?
|
|
262
|
+
allowed = @agent_config['tools']
|
|
263
|
+
allowed.nil? || Array(allowed).include?('recall_memories')
|
|
264
|
+
end
|
|
265
|
+
|
|
238
266
|
def pinned_memory_context
|
|
239
267
|
channel_mode = @parent_channel.respond_to?(:mode) ? @parent_channel.mode : nil
|
|
240
268
|
# For sub-agents spawned from Slack, use the Slack channel's pinned memories.
|
|
@@ -6,7 +6,7 @@ module RailsConsoleAi
|
|
|
6
6
|
attr_reader :definitions, :last_sub_agent_usage
|
|
7
7
|
|
|
8
8
|
# Tools that should never be cached (side effects or user interaction)
|
|
9
|
-
NO_CACHE = %w[ask_user save_memory delete_memory recall_memory execute_code execute_plan activate_skill save_skill delete_skill save_agent delete_agent delegate_task explore_output].freeze
|
|
9
|
+
NO_CACHE = %w[ask_user save_memory delete_memory recall_memory execute_code execute_plan activate_skill save_skill delete_skill save_agent delete_agent delegate_task explore_output save_artifact list_artifacts read_artifact delete_artifact].freeze
|
|
10
10
|
|
|
11
11
|
def initialize(executor: nil, mode: :default, channel: nil, allowed_tools: nil)
|
|
12
12
|
@executor = executor
|
|
@@ -258,6 +258,236 @@ module RailsConsoleAi
|
|
|
258
258
|
register_execute_plan
|
|
259
259
|
register_delegate_task
|
|
260
260
|
end
|
|
261
|
+
|
|
262
|
+
register_artifact_tools
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
ARTIFACT_TYPES = %w[text/html text/markdown text/csv application/json text/plain].freeze
|
|
267
|
+
|
|
268
|
+
# How much of an artifact read_artifact returns inline before handing back
|
|
269
|
+
# an output_id instead. A full report would otherwise swamp the context.
|
|
270
|
+
ARTIFACT_READ_LIMIT = 12_000
|
|
271
|
+
|
|
272
|
+
def register_artifact_tools
|
|
273
|
+
register(
|
|
274
|
+
name: 'save_artifact',
|
|
275
|
+
description: 'Save a generated file — an HTML report, a CSV extract, a JSON dump — so the user can open it in a browser ' \
|
|
276
|
+
'instead of reading it in the console. Use this whenever the user asks for a summary, report, export or page, ' \
|
|
277
|
+
'or whenever the output is a document rather than an answer. ' \
|
|
278
|
+
'Returns a URL to view it. Prefer this over printing hundreds of lines of HTML or CSV into the transcript. ' \
|
|
279
|
+
'New artifacts are stamped with today\'s date automatically — pass a plain title like "Q3 churn summary", ' \
|
|
280
|
+
'not a dated one. Saving under a name that already exists REPLACES its content and bumps its revision; ' \
|
|
281
|
+
'to revise an artifact from an earlier day pass its exact handle (e.g. "20260911_Q3 churn summary") as the name, ' \
|
|
282
|
+
'and call read_artifact first if you need to keep most of what is already there. ' \
|
|
283
|
+
'HTML is rendered in a sandboxed frame: inline CSS works, but scripts never run, so do not rely on JavaScript ' \
|
|
284
|
+
'or on loading anything over the network — inline all styles and embed any data directly. ' \
|
|
285
|
+
'IMPORTANT: content must be the complete literal text, never a placeholder to fill in later. ' \
|
|
286
|
+
'If the content already exists as a value in the console — a file you read, a string you built — do NOT ' \
|
|
287
|
+
'retype it here. Your response has an output-token limit and a large file will be cut off mid-argument, ' \
|
|
288
|
+
'discarding the call. Use execute_code instead and save it directly from Ruby: ' \
|
|
289
|
+
'RailsConsoleAi.save_artifact(name: "Q3 churn summary", content: the_string). Same naming and revision ' \
|
|
290
|
+
'rules, and it returns the URL.',
|
|
291
|
+
parameters: {
|
|
292
|
+
'type' => 'object',
|
|
293
|
+
'properties' => {
|
|
294
|
+
'name' => { 'type' => 'string', 'description' => 'Short title, e.g. "Q3 churn summary". Reusing an existing name updates that artifact.' },
|
|
295
|
+
'content_type' => {
|
|
296
|
+
'type' => 'string',
|
|
297
|
+
'enum' => ARTIFACT_TYPES,
|
|
298
|
+
'description' => 'MIME type of the content'
|
|
299
|
+
},
|
|
300
|
+
'content' => { 'type' => 'string', 'description' => 'The full, literal file content — not a placeholder. Replaces the previous content entirely. For content that already exists as a console value, use RailsConsoleAi.save_artifact from execute_code instead.' },
|
|
301
|
+
'description' => { 'type' => 'string', 'description' => 'Optional one-line note about what this is and how it was built' }
|
|
302
|
+
},
|
|
303
|
+
'required' => ['name', 'content_type', 'content']
|
|
304
|
+
},
|
|
305
|
+
handler: ->(args) {
|
|
306
|
+
save_artifact(args['name'], args['content_type'], args['content'], args['description'])
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
register(
|
|
311
|
+
name: 'list_artifacts',
|
|
312
|
+
description: 'List saved artifacts, newest first, with the handle each one is addressed by. ' \
|
|
313
|
+
'Call this when the user refers to an artifact from an earlier session ("update the churn report") ' \
|
|
314
|
+
'and you do not already know its handle.',
|
|
315
|
+
parameters: {
|
|
316
|
+
'type' => 'object',
|
|
317
|
+
'properties' => {
|
|
318
|
+
'search' => { 'type' => 'string', 'description' => 'Optional filter over name and description' }
|
|
319
|
+
},
|
|
320
|
+
'required' => []
|
|
321
|
+
},
|
|
322
|
+
handler: ->(args) { list_artifacts(args['search']) }
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
register(
|
|
326
|
+
name: 'read_artifact',
|
|
327
|
+
description: 'Read back the content of a saved artifact so you can revise it. ' \
|
|
328
|
+
'Accepts the handle, the name, or the token from its URL.',
|
|
329
|
+
parameters: {
|
|
330
|
+
'type' => 'object',
|
|
331
|
+
'properties' => {
|
|
332
|
+
'handle' => { 'type' => 'string', 'description' => 'Handle, name, or token, e.g. "q3-churn-summary"' }
|
|
333
|
+
},
|
|
334
|
+
'required' => ['handle']
|
|
335
|
+
},
|
|
336
|
+
handler: ->(args) { read_artifact(args['handle']) }
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
register(
|
|
340
|
+
name: 'delete_artifact',
|
|
341
|
+
description: 'Delete a saved artifact. Only when the user asks for it to be removed.',
|
|
342
|
+
parameters: {
|
|
343
|
+
'type' => 'object',
|
|
344
|
+
'properties' => {
|
|
345
|
+
'handle' => { 'type' => 'string', 'description' => 'Handle, name, or token' }
|
|
346
|
+
},
|
|
347
|
+
'required' => ['handle']
|
|
348
|
+
},
|
|
349
|
+
handler: ->(args) { delete_artifact(args['handle']) }
|
|
350
|
+
)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Referencing the constant is what makes Rails autoload it. `defined?` does
|
|
354
|
+
# not: under autoloading the constant is absent until something asks for it
|
|
355
|
+
# by name, so a `defined?` guard reports "no model" in a perfectly healthy
|
|
356
|
+
# app and the tool never runs. NameError is the real "no engine here" case.
|
|
357
|
+
def artifact_model
|
|
358
|
+
RailsConsoleAi::Artifact
|
|
359
|
+
rescue NameError
|
|
360
|
+
nil
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Nil when artifacts can't be used at all; otherwise the model class.
|
|
364
|
+
# Returns the explanation as a String so each tool can hand it straight back.
|
|
365
|
+
def artifact_store
|
|
366
|
+
model = artifact_model
|
|
367
|
+
return [nil, 'artifacts need the Rails engine loaded.'] unless model
|
|
368
|
+
unless model.available?
|
|
369
|
+
return [nil, 'the rails_console_ai_artifacts table does not exist. ' \
|
|
370
|
+
'Ask the user to run `ai_db_setup` in the Rails console, then try again.']
|
|
371
|
+
end
|
|
372
|
+
[model, nil]
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Addressed by slug so the link itself says what it points at. Absolute
|
|
376
|
+
# when the app's base URL is known: this string is read in a terminal,
|
|
377
|
+
# pasted into Slack and opened on a phone, none of which resolve a path.
|
|
378
|
+
def artifact_url(artifact)
|
|
379
|
+
"#{RailsConsoleAi.base_url}#{RailsConsoleAi.engine_mount_path}/artifacts/#{artifact.slug}"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# The write itself lives on the model, shared with RailsConsoleAi.save_artifact
|
|
383
|
+
# so a console-side save and a tool-side save land identically.
|
|
384
|
+
def save_artifact(name, content_type, content, description = nil)
|
|
385
|
+
model, problem = artifact_store
|
|
386
|
+
return "FAILED to save artifact: #{problem}" if problem
|
|
387
|
+
|
|
388
|
+
existing = model.find_by(slug: model.slug_for(model.stamped_name(name)))
|
|
389
|
+
verb = existing ? 'updated' : 'saved'
|
|
390
|
+
|
|
391
|
+
artifact = model.store(name: name, content_type: content_type,
|
|
392
|
+
content: content, description: description)
|
|
393
|
+
|
|
394
|
+
if artifact.errors.any? || artifact.new_record?
|
|
395
|
+
return "FAILED to save artifact: #{artifact.errors.full_messages.join('; ')}"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
url = artifact_url(artifact)
|
|
399
|
+
revision_note = existing ? " revision #{artifact.revision}," : ''
|
|
400
|
+
if @channel
|
|
401
|
+
@channel.display_status(" Artifact #{verb}: #{artifact.name} (#{artifact.human_size}) #{url}")
|
|
402
|
+
end
|
|
403
|
+
"Artifact #{verb}: \"#{artifact.name}\" (handle: #{artifact.slug},#{revision_note} " \
|
|
404
|
+
"#{artifact.content_type}, #{artifact.human_size}). " \
|
|
405
|
+
"The user can view it at #{url} — give them that URL. " \
|
|
406
|
+
"Refer to it later as #{artifact.slug}."
|
|
407
|
+
rescue ::ActiveRecord::ActiveRecordError => e
|
|
408
|
+
"FAILED to save artifact (#{e.class}: #{e.message})."
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def list_artifacts(search = nil)
|
|
412
|
+
model, problem = artifact_store
|
|
413
|
+
return "FAILED to list artifacts: #{problem}" if problem
|
|
414
|
+
|
|
415
|
+
scope = model.newest_first
|
|
416
|
+
search = search.to_s.strip
|
|
417
|
+
unless search.empty?
|
|
418
|
+
needle = "%#{search.downcase}%"
|
|
419
|
+
scope = scope.where("LOWER(name) LIKE :q OR LOWER(COALESCE(description, '')) LIKE :q", q: needle)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
rows = scope.limit(50).to_a
|
|
423
|
+
if rows.empty?
|
|
424
|
+
return search.empty? ? 'No artifacts saved yet.' : "No artifacts match #{search.inspect}."
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
lines = rows.map { |a|
|
|
428
|
+
bits = ["#{a.slug} — \"#{a.name}\"", a.content_type, a.human_size]
|
|
429
|
+
bits << "rev #{a.revision}" if a.revision.to_i > 1
|
|
430
|
+
bits << a.updated_at.strftime('%Y-%m-%d')
|
|
431
|
+
line = bits.join(', ')
|
|
432
|
+
line += "\n #{a.description}" if a.description.present?
|
|
433
|
+
"- #{line}"
|
|
434
|
+
}
|
|
435
|
+
"#{rows.length} artifact#{rows.length == 1 ? '' : 's'} (address one by its handle):\n#{lines.join("\n")}"
|
|
436
|
+
rescue ::ActiveRecord::ActiveRecordError => e
|
|
437
|
+
"FAILED to list artifacts (#{e.class}: #{e.message})."
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def read_artifact(handle)
|
|
441
|
+
model, problem = artifact_store
|
|
442
|
+
return "FAILED to read artifact: #{problem}" if problem
|
|
443
|
+
|
|
444
|
+
artifact = model.find_by_handle(handle)
|
|
445
|
+
return artifact_not_found(model, handle) unless artifact
|
|
446
|
+
|
|
447
|
+
header = "Artifact \"#{artifact.name}\" (handle: #{artifact.slug}, #{artifact.content_type}, " \
|
|
448
|
+
"#{artifact.human_size}, revision #{artifact.revision}):\n"
|
|
449
|
+
|
|
450
|
+
body = artifact.content.to_s
|
|
451
|
+
return header + body if body.length <= ARTIFACT_READ_LIMIT
|
|
452
|
+
|
|
453
|
+
# Same escape hatch large tool results use: keep the head, park the rest
|
|
454
|
+
# where explore_output can query it rather than flooding the context.
|
|
455
|
+
output_id = @executor && @executor.store_output(body)
|
|
456
|
+
preview = body[0, ARTIFACT_READ_LIMIT]
|
|
457
|
+
tail = if output_id
|
|
458
|
+
"\n\n[Truncated at #{ARTIFACT_READ_LIMIT} of #{body.length} chars — " \
|
|
459
|
+
"use explore_output with output_id=#{output_id} to query the rest, " \
|
|
460
|
+
"or recall_output to expand it in place]"
|
|
461
|
+
else
|
|
462
|
+
"\n\n[Truncated at #{ARTIFACT_READ_LIMIT} of #{body.length} chars]"
|
|
463
|
+
end
|
|
464
|
+
header + preview + tail
|
|
465
|
+
rescue ::ActiveRecord::ActiveRecordError => e
|
|
466
|
+
"FAILED to read artifact (#{e.class}: #{e.message})."
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def delete_artifact(handle)
|
|
470
|
+
model, problem = artifact_store
|
|
471
|
+
return "FAILED to delete artifact: #{problem}" if problem
|
|
472
|
+
|
|
473
|
+
artifact = model.find_by_handle(handle)
|
|
474
|
+
return artifact_not_found(model, handle) unless artifact
|
|
475
|
+
|
|
476
|
+
name = artifact.name
|
|
477
|
+
slug = artifact.slug
|
|
478
|
+
artifact.destroy
|
|
479
|
+
@channel.display_status(" Deleted artifact: #{name}") if @channel
|
|
480
|
+
"Artifact deleted: \"#{name}\" (#{slug})."
|
|
481
|
+
rescue ::ActiveRecord::ActiveRecordError => e
|
|
482
|
+
"FAILED to delete artifact (#{e.class}: #{e.message})."
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def artifact_not_found(model, handle)
|
|
486
|
+
available = model.newest_first.limit(20).pluck(:slug)
|
|
487
|
+
if available.empty?
|
|
488
|
+
"No artifact found for #{handle.inspect}. There are no saved artifacts."
|
|
489
|
+
else
|
|
490
|
+
"No artifact found for #{handle.inspect}. Available handles: #{available.join(', ')}"
|
|
261
491
|
end
|
|
262
492
|
end
|
|
263
493
|
|