insika 0.1.0 → 0.2.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.
Files changed (182) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -3
  3. data/README.md +1 -1
  4. data/bin/insika +22 -7
  5. data/docs/AGENTS.md +129 -5
  6. data/docs/CHANNELS.md +1 -1
  7. data/docs/CONTEXT.md +22 -5
  8. data/docs/DEPLOY.md +30 -10
  9. data/docs/EMBEDDING.md +11 -7
  10. data/docs/EVALS.md +1 -1
  11. data/docs/LOADTEST.md +3 -2
  12. data/docs/OBSERVABILITY.md +11 -2
  13. data/docs/REFINEMENT.md +6 -6
  14. data/docs/RELEASING.md +7 -7
  15. data/docs/RUNNING-LOCAL.md +1 -1
  16. data/docs/SECURITY.md +24 -11
  17. data/docs/SKILLS.md +189 -3
  18. data/docs/WHY.md +1 -1
  19. data/docs/WORKFLOWS.md +2 -2
  20. data/docs/index.md +1 -1
  21. data/docs/onboarding/start.md +1 -1
  22. data/lib/insika/agent_profile.rb +89 -22
  23. data/lib/insika/alert_dispatcher.rb +139 -0
  24. data/lib/insika/baseline_store.rb +2 -2
  25. data/lib/insika/budget_ledger.rb +135 -0
  26. data/lib/insika/channel_delivery.rb +14 -11
  27. data/lib/insika/channel_registry.rb +1 -1
  28. data/lib/insika/channels/relay.rb +3 -3
  29. data/lib/insika/channels/web/widget.js +2 -2
  30. data/lib/insika/channels/web.rb +7 -7
  31. data/lib/insika/channels/webhook.rb +58 -0
  32. data/lib/insika/chat_builder.rb +62 -13
  33. data/lib/insika/circuit_state.rb +114 -0
  34. data/lib/insika/coercion.rb +8 -0
  35. data/lib/insika/commands/agent_payload.rb +5 -3
  36. data/lib/insika/commands/create_agent.rb +2 -2
  37. data/lib/insika/commands/create_session.rb +1 -1
  38. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  39. data/lib/insika/commands/delete_skill.rb +43 -0
  40. data/lib/insika/commands/gate_refinement.rb +12 -12
  41. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  42. data/lib/insika/commands/import_tools.rb +4 -4
  43. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  44. data/lib/insika/commands/resolve_refinement.rb +1 -1
  45. data/lib/insika/commands/revoke_token.rb +39 -0
  46. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  47. data/lib/insika/commands/run_refinement.rb +5 -5
  48. data/lib/insika/commands/send_message.rb +9 -9
  49. data/lib/insika/commands/set_agent_tools.rb +1 -1
  50. data/lib/insika/commands/set_skill_agents.rb +60 -19
  51. data/lib/insika/commands/trigger_workflow.rb +1 -1
  52. data/lib/insika/commands/update_agent.rb +1 -1
  53. data/lib/insika/commands/write_data_tool.rb +1 -1
  54. data/lib/insika/commands/write_golden.rb +1 -1
  55. data/lib/insika/commands/write_skill.rb +19 -9
  56. data/lib/insika/config_store.rb +8 -4
  57. data/lib/insika/context/builder.rb +2 -2
  58. data/lib/insika/context/fragment.rb +27 -3
  59. data/lib/insika/context/priority.rb +3 -2
  60. data/lib/insika/context/providers/memory.rb +1 -1
  61. data/lib/insika/context/providers/request.rb +1 -1
  62. data/lib/insika/context/providers/session.rb +17 -2
  63. data/lib/insika/context/providers/skill.rb +5 -1
  64. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  65. data/lib/insika/context_trace_store.rb +92 -0
  66. data/lib/insika/delegation_store.rb +2 -2
  67. data/lib/insika/doctor.rb +250 -5
  68. data/lib/insika/dsl/runtime.rb +12 -9
  69. data/lib/insika/dsl/server_boot.rb +4 -3
  70. data/lib/insika/dsl/system.rb +1 -1
  71. data/lib/insika/dsl.rb +72 -15
  72. data/lib/insika/edge_limiter.rb +144 -6
  73. data/lib/insika/egress_guard.rb +3 -3
  74. data/lib/insika/env_schema.rb +13 -10
  75. data/lib/insika/errors.rb +61 -5
  76. data/lib/insika/evals/assertions.rb +12 -12
  77. data/lib/insika/evals/baseline.rb +3 -3
  78. data/lib/insika/evals/golden.rb +8 -8
  79. data/lib/insika/evals/judge.rb +7 -7
  80. data/lib/insika/evals/pairwise.rb +3 -3
  81. data/lib/insika/evals/report.rb +2 -2
  82. data/lib/insika/evals/runner.rb +6 -6
  83. data/lib/insika/evals/transport.rb +2 -2
  84. data/lib/insika/event_stream.rb +23 -5
  85. data/lib/insika/executor.rb +423 -108
  86. data/lib/insika/frontmatter.rb +1 -1
  87. data/lib/insika/golden_store.rb +2 -2
  88. data/lib/insika/http_client.rb +3 -3
  89. data/lib/insika/inbound_log.rb +1 -1
  90. data/lib/insika/llm_configurator.rb +3 -3
  91. data/lib/insika/loop_detector.rb +143 -0
  92. data/lib/insika/mcp_http_client.rb +4 -4
  93. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  94. data/lib/insika/message_origin.rb +2 -2
  95. data/lib/insika/model_resolver.rb +1 -1
  96. data/lib/insika/model_selection.rb +5 -4
  97. data/lib/insika/onboarding.rb +2 -2
  98. data/lib/insika/outbox_store.rb +2 -2
  99. data/lib/insika/overlay_tool_registry.rb +3 -4
  100. data/lib/insika/pack.rb +3 -3
  101. data/lib/insika/pack_importer.rb +17 -15
  102. data/lib/insika/pending_action_store.rb +1 -1
  103. data/lib/insika/plugin/loader.rb +2 -2
  104. data/lib/insika/policy/policy.rb +1 -1
  105. data/lib/insika/profile_source.rb +12 -6
  106. data/lib/insika/provider_error_classifier.rb +160 -0
  107. data/lib/insika/queue_policy.rb +2 -2
  108. data/lib/insika/recovery.rb +47 -6
  109. data/lib/insika/refinement/candidate.rb +4 -4
  110. data/lib/insika/refinement/evidence_collector.rb +6 -6
  111. data/lib/insika/refinement/gate.rb +7 -7
  112. data/lib/insika/refinement/panel.rb +7 -7
  113. data/lib/insika/refinement/proposer.rb +9 -9
  114. data/lib/insika/refinement_store.rb +12 -12
  115. data/lib/insika/reliability.rb +185 -0
  116. data/lib/insika/safety/config.rb +2 -2
  117. data/lib/insika/safety/detectors.rb +5 -5
  118. data/lib/insika/safety/factory.rb +3 -3
  119. data/lib/insika/safety/input_guardrail.rb +19 -4
  120. data/lib/insika/safety/moderator.rb +19 -11
  121. data/lib/insika/safety/output_filter.rb +2 -2
  122. data/lib/insika/safety/output_validator.rb +2 -2
  123. data/lib/insika/safety/safe_responses.rb +1 -1
  124. data/lib/insika/sandbox/boundary.rb +2 -2
  125. data/lib/insika/sandbox.rb +1 -1
  126. data/lib/insika/server/app.rb +223 -51
  127. data/lib/insika/server/boot.rb +4 -4
  128. data/lib/insika/server/rack_app.rb +15 -7
  129. data/lib/insika/server/responses.rb +18 -8
  130. data/lib/insika/server/tenant_auth.rb +61 -0
  131. data/lib/insika/session_actor.rb +3 -3
  132. data/lib/insika/session_store.rb +1 -1
  133. data/lib/insika/settings_store.rb +5 -5
  134. data/lib/insika/shutdown.rb +4 -4
  135. data/lib/insika/skill_catalog.rb +127 -20
  136. data/lib/insika/skill_store.rb +70 -22
  137. data/lib/insika/steer_injector.rb +1 -1
  138. data/lib/insika/store.rb +1 -1
  139. data/lib/insika/studio/app.rb +183 -61
  140. data/lib/insika/studio/assets/dist/application.js +25 -24
  141. data/lib/insika/studio/forms.rb +13 -18
  142. data/lib/insika/studio/nav_icons.rb +1 -1
  143. data/lib/insika/studio/views/_message.erb +2 -2
  144. data/lib/insika/studio/views/agent_detail.erb +2 -2
  145. data/lib/insika/studio/views/agents.erb +1 -1
  146. data/lib/insika/studio/views/refinement.erb +4 -4
  147. data/lib/insika/studio/views/session.erb +78 -3
  148. data/lib/insika/studio/views/settings.erb +7 -12
  149. data/lib/insika/studio/views/skills.erb +67 -12
  150. data/lib/insika/subagent_graph.rb +3 -3
  151. data/lib/insika/task_actor.rb +3 -3
  152. data/lib/insika/task_store.rb +1 -1
  153. data/lib/insika/telemetry/pricing.rb +3 -3
  154. data/lib/insika/telemetry/recorder.rb +1 -1
  155. data/lib/insika/telemetry.rb +2 -2
  156. data/lib/insika/testing/store_contract.rb +27 -27
  157. data/lib/insika/tick.rb +122 -0
  158. data/lib/insika/token_store.rb +168 -0
  159. data/lib/insika/tool_assembly.rb +5 -5
  160. data/lib/insika/tool_definition.rb +8 -8
  161. data/lib/insika/tool_envelope.rb +1 -1
  162. data/lib/insika/tool_manifest.rb +6 -6
  163. data/lib/insika/tool_output_compressor.rb +100 -0
  164. data/lib/insika/tool_store.rb +1 -1
  165. data/lib/insika/tool_trace_store.rb +1 -1
  166. data/lib/insika/tools/concurrency.rb +2 -2
  167. data/lib/insika/tools/data_defined_tool.rb +4 -5
  168. data/lib/insika/tools/load_skill.rb +61 -3
  169. data/lib/insika/tools/stuck_signal.rb +44 -0
  170. data/lib/insika/tools/subagent.rb +4 -4
  171. data/lib/insika/tools/subagents.rb +1 -1
  172. data/lib/insika/turn_output.rb +2 -2
  173. data/lib/insika/turn_state.rb +17 -13
  174. data/lib/insika/turn_timing.rb +2 -2
  175. data/lib/insika/usage_ledger.rb +1 -1
  176. data/lib/insika/version.rb +1 -1
  177. data/lib/insika/wiring/graph.rb +77 -26
  178. data/lib/insika/workflow.rb +1 -1
  179. data/lib/insika/workflow_registry.rb +1 -1
  180. data/lib/insika.rb +32 -15
  181. metadata +19 -2
  182. data/lib/insika/server/admin_auth.rb +0 -29
@@ -30,8 +30,8 @@ module Studio
30
30
  # Same-origin assets: versioned esbuild bundle in `assets/dist/*`, served
31
31
  # by `/studio/assets/dist/*`. Strict `'self'` CSP (no `unsafe-inline`).
32
32
  class App < Roda
33
- include Forms # form -> command-payload parsing (§11 B6)
34
- include NavIcons # nav SVG helper (§11 B6)
33
+ include Forms # form -> command-payload parsing
34
+ include NavIcons # nav SVG helper
35
35
 
36
36
  # The session cookie lives N days. 7 days = parity with the OpenClaw default.
37
37
  SESSION_MAX_AGE = 7 * 24 * 3600
@@ -82,7 +82,7 @@ module Studio
82
82
  agent_file_store: nil, skill_store: nil, skill_catalog: nil,
83
83
  tool_catalog: nil, tool_store: nil, memory_store: nil, session_store: nil,
84
84
  settings_store: nil, llm_provider_store: nil, mcp_store: nil,
85
- system_file_store: nil, tool_trace_store: nil,
85
+ system_file_store: nil, tool_trace_store: nil, context_trace_store: nil,
86
86
  task_store: nil, checkpoint_store: nil, pending_action_store: nil,
87
87
  refinement_store: nil, golden_store: nil, session_secret: nil)
88
88
  @insika = {
@@ -90,7 +90,7 @@ module Studio
90
90
  event_stream: event_stream, config: config,
91
91
  agent_file_store: agent_file_store, skill_store: skill_store,
92
92
  skill_catalog: skill_catalog, tool_catalog: tool_catalog,
93
- # tool_store: DATA-DEFINED tool definitions (Phase 5). The catalog already
93
+ # tool_store: DATA-DEFINED tool definitions. The catalog already
94
94
  # shows the data-tools in the matrix; the store feeds the authoring page.
95
95
  tool_store: tool_store,
96
96
  memory_store: memory_store, session_store: session_store,
@@ -99,17 +99,20 @@ module Studio
99
99
  settings_store: settings_store, llm_provider_store: llm_provider_store,
100
100
  mcp_store: mcp_store, system_file_store: system_file_store,
101
101
  # per-session tool-call trace (debug): args + result + status per
102
- # turn, rendered in the session viewer (FOLLOWUP §3.1).
102
+ # turn, rendered in the session viewer.
103
103
  tool_trace_store: tool_trace_store,
104
- # operate: tasks + human-in-the-loop approvals (§12 G5). Reads the
104
+ # per-session context breakdown: tokens by category +
105
+ # budget per turn, on the same viewer. Counts only, no content.
106
+ context_trace_store: context_trace_store,
107
+ # operate: tasks + human-in-the-loop approvals. Reads the
105
108
  # task/checkpoint/pending stores to render; controls (pause/resume/
106
109
  # cancel/approve) dispatch on the bus — parity with server/admin.
107
110
  task_store: task_store, checkpoint_store: checkpoint_store,
108
111
  pending_action_store: pending_action_store,
109
- # refinement runs (RFC-0013 phase A): the ranked failure report per agent.
112
+ # refinement runs: the ranked failure report per agent.
110
113
  # Read-only here; the "Run" button dispatches :run_refinement on the bus.
111
114
  refinement_store: refinement_store,
112
- # eval cases (RFC-0008 §3.1): read to render; writes go through :write_golden.
115
+ # eval cases: read to render; writes go through:write_golden.
113
116
  golden_store: golden_store
114
117
  }.freeze
115
118
  # "restart recommended" flag — in memory, PER PROCESS. A
@@ -304,15 +307,53 @@ module Studio
304
307
  end
305
308
  end
306
309
 
307
- # Agent skills → :update_agent with the `skills` allowlist.
308
- # "all" = nil; otherwise the checked subset (possibly []).
309
- r.post "skills" do
310
- check_csrf!
311
- skills = r.params["all_skills"] == "1" ? nil : Array(r.params["skills"]).map(&:to_s)
312
- with_flash("Skills updated.") do
313
- dispatch(:update_agent, { id: id, skills: skills })
310
+ r.on "skills" do
311
+ # POST /studio/agents/:id/skills → :update_agent with the `skills`
312
+ # allowlist. "all" = nil; otherwise the checked subset (possibly []).
313
+ r.is do
314
+ r.post do
315
+ check_csrf!
316
+ skills = r.params["all_skills"] == "1" ? nil : Array(r.params["skills"]).map(&:to_s)
317
+ with_flash("Skills updated.") do
318
+ dispatch(:update_agent, { id: id, skills: skills })
319
+ end
320
+ r.redirect(agent_path(id))
321
+ end
322
+ end
323
+
324
+ # /studio/agents/:id/skills/:name — this agent's OWN version of a skill.
325
+ # TWO path segments, which is exactly why the store takes the agent as a
326
+ # second argument: a composite "agent/name" key would put a `/` inside
327
+ # what this route serves as one segment.
328
+ r.on String do |name|
329
+ name = utf8(name)
330
+ r.is do
331
+ r.get do
332
+ load_skills_master(agent: id)
333
+ @selected = name
334
+ @scope_agent = id
335
+ @skill_content = skill_source(name, agent: id)
336
+ view("skills")
337
+ end
338
+ # Saving the specialization: same command, `agent:` set.
339
+ r.post do
340
+ check_csrf!
341
+ with_flash("Specialization saved.") do
342
+ dispatch(:write_skill, { name: name, agent: id, content: r.params["content"].to_s })
343
+ end
344
+ r.redirect("#{agent_path(id)}/skills/#{Rack::Utils.escape(name)}")
345
+ end
346
+ end
347
+
348
+ # Un-specialize: the shared skill stays, and the agent falls back to it.
349
+ r.post "delete" do
350
+ check_csrf!
351
+ with_flash("Specialization removed — the agent falls back to the shared skill.") do
352
+ dispatch(:delete_skill, { name: name, agent: id })
353
+ end
354
+ r.redirect("/studio/skills/#{Rack::Utils.escape(name)}")
355
+ end
314
356
  end
315
- r.redirect(agent_path(id))
316
357
  end
317
358
 
318
359
  # Agent memory. Scoped by tenant = agent id — the
@@ -381,25 +422,37 @@ module Studio
381
422
  view("skills")
382
423
  end
383
424
  end
384
- # Enables/disables the skill on N agents at once.
425
+ # Enables/disables the skill on N agents at once, and marks it always-on for
426
+ # the ones that checked `eager`. Both are per-agent decisions about the same
427
+ # skill, so they are the same form and the same dispatch.
385
428
  r.post "agents" do
386
429
  check_csrf!
387
430
  agent_ids = Array(r.params["agent_ids"]).map(&:to_s)
431
+ eager_ids = Array(r.params["eager_ids"]).map(&:to_s)
388
432
  result = with_flash("Skill's agents updated.") do
389
- dispatch(:set_skill_agents, { name: name, agent_ids: agent_ids })
390
- end
391
- skipped = Array(result && result[:skipped_all])
392
- if skipped.any?
393
- flash["notice"] = "#{flash['notice']} — #{skipped.size} agent(s) with 'all' skills were left intact."
433
+ dispatch(:set_skill_agents, { name: name, agent_ids: agent_ids, eager_ids: eager_ids })
394
434
  end
435
+ note_skipped(result)
395
436
  r.redirect("/studio/skills")
396
437
  end
438
+
439
+ # Seeds a per-agent override from the SHARED body and opens it for editing.
440
+ # Seeded and not blank: specializing means "this, but for me" — starting from
441
+ # an empty editor is how the two versions drift apart on day one.
442
+ r.post "specialize" do
443
+ check_csrf!
444
+ agent_id = presence(r.params["agent_id"])
445
+ with_flash("Specialized for #{agent_id}.") do
446
+ dispatch(:write_skill, { name: name, agent: agent_id, content: skill_source(name) })
447
+ end
448
+ r.redirect("/studio/agents/#{Rack::Utils.escape(agent_id.to_s)}/skills/#{Rack::Utils.escape(name)}")
449
+ end
397
450
  end
398
451
  end
399
452
 
400
453
  # --- Tools: tool × agent matrix + DATA-DEFINED tool authoring -----------
401
454
  r.on "tools" do
402
- # Data-defined tool authoring (Phase 5). Under /tools/def/* — BEFORE the
455
+ # Data-defined tool authoring. Under /tools/def/* — BEFORE the
403
456
  # generic `r.post String` matcher (which is the allow/deny matrix per agent :id).
404
457
  r.on "def" do
405
458
  # /studio/tools/def/new — empty editor.
@@ -474,7 +527,7 @@ module Studio
474
527
  r.on "settings" do
475
528
  r.is do
476
529
  r.get { render_settings }
477
- # General settings (streaming/timeouts/compaction) → :update_settings.
530
+ # General settings (streaming/timeouts) → :update_settings.
478
531
  r.post do
479
532
  check_csrf!
480
533
  with_flash("Settings saved.") do
@@ -484,7 +537,7 @@ module Studio
484
537
  end
485
538
  end
486
539
 
487
- # Platform model defaults (sub-resource, v2 §10): its own form/route so a
540
+ # Platform model defaults (sub-resource, v2): its own form/route so a
488
541
  # general-settings save never clobbers the model layer.
489
542
  r.post "models" do
490
543
  check_csrf!
@@ -494,7 +547,7 @@ module Studio
494
547
  r.redirect("/studio/settings?s=models")
495
548
  end
496
549
 
497
- # Per-model reasoning defaults (§10, the per-model layer): its own form so a
550
+ # Per-model reasoning defaults (the per-model layer): its own form so a
498
551
  # model-defaults save never clobbers the per-model map, and vice-versa.
499
552
  r.post "model-params" do
500
553
  check_csrf!
@@ -504,7 +557,7 @@ module Studio
504
557
  r.redirect("/studio/settings?s=models")
505
558
  end
506
559
 
507
- # Edge limits (item 33 / §12 G7): the platform rate-limit/cost layer.
560
+ # Edge limits: the platform rate-limit/cost layer.
508
561
  # Its own form for the same reason as models — saves never cross-clobber.
509
562
  r.post "edge" do
510
563
  check_csrf!
@@ -514,7 +567,7 @@ module Studio
514
567
  r.redirect("/studio/settings?s=edge")
515
568
  end
516
569
 
517
- # Evals (RFC-0008 / RFC-0013 §3.9): the judge PANEL and how it agrees. Its own
570
+ # Evals: the judge PANEL and how it agrees. Its own
518
571
  # form, like models and edge — a save here must not clobber those.
519
572
  r.post "evals" do
520
573
  check_csrf!
@@ -619,12 +672,14 @@ module Studio
619
672
  # Session's tool-call trace (debug): grouped by turn in the view.
620
673
  @tool_traces = (insika[:tool_trace_store]&.for_session(sid) || [])
621
674
  .group_by { |t| t["turn"] }
675
+ # Context breakdown per turn: chronological entries.
676
+ @context_traces = insika[:context_trace_store]&.for_session(sid) || []
622
677
  view("session")
623
678
  end
624
679
  end
625
680
  end
626
681
 
627
- # --- Tasks: list + detail + operator controls (§12 G5) -------
682
+ # Tasks: list + detail + operator controls -------
628
683
  # Parity with server/admin: READS the task/checkpoint/pending stores to
629
684
  # render; pause/resume/cancel dispatch Commands on the bus (never a direct
630
685
  # store write). Every control audits the ATTEMPT to the EventStream first.
@@ -658,7 +713,7 @@ module Studio
658
713
  end
659
714
  end
660
715
 
661
- # --- Approvals: human-in-the-loop inbox (§12 G5) -------------
716
+ # Approvals: human-in-the-loop inbox -------------
662
717
  # Lists every :pending action across tasks; resolving one dispatches
663
718
  # :approve_action, which resolves the store AND wakes the suspended turn.
664
719
  r.on "approvals" do
@@ -677,7 +732,7 @@ module Studio
677
732
  end
678
733
  end
679
734
 
680
- # --- Evals: the cases that grade an agent (RFC-0008 §3.1) -------
735
+ # Evals: the cases that grade an agent -------
681
736
  # A case is DATA in the same YAML shape the corpus files use, so what an operator
682
737
  # edits here is what a pull request would review. The one loader validates it, on
683
738
  # the way in — a malformed case is a red flash, never a silently skipped test.
@@ -702,8 +757,8 @@ r.on "evals" do
702
757
  end
703
758
 
704
759
  # --- Refinement: what broke in real traffic, and what to do about it --
705
- # `POST /refinement` runs the report (RFC-0013 phase A). `POST
706
- # /refinement/propose` is phase C: the configured model writes a candidate from
760
+ # `POST /refinement` runs the report. `POST
761
+ # refinement/propose` is: the configured model writes a candidate from
707
762
  # the findings and the gate scores it by replaying the golden set. `POST
708
763
  # /refinement/resolve` is a human approving or rejecting what the gate passed.
709
764
  # All three go through the bus like every other Studio write — this page reads
@@ -755,7 +810,7 @@ end
755
810
  @agent = presence(r.params["agent"]) || default_agent
756
811
  @session_id = presence(r.params["session_id"])
757
812
  @agents = insika[:profile_source].ids.sort
758
- # Server-side echo + continuity (§11 A1): render the session's persisted
813
+ # Server-side echo + continuity: render the session's persisted
759
814
  # transcript as bubbles. The user's message is only persisted at the END
760
815
  # of the turn, so the just-sent message rides a one-shot flash bubble
761
816
  # (@sent_message) until it lands in history — an optimistic JS echo can't
@@ -772,7 +827,7 @@ end
772
827
  # Blank session = new conversation: created via Command (create_session
773
828
  # generates the id — the Studio doesn't write to the store directly). A typed id
774
829
  # continues an existing conversation (send_message requires it to exist).
775
- # The per-chat model pin (v2, §10) is set at creation and rides the whole
830
+ # The per-chat model pin is set at creation and rides the whole
776
831
  # conversation, so it only applies to a NEW session — an existing one keeps
777
832
  # whatever it was pinned to.
778
833
  session_id = typed_session ||
@@ -780,7 +835,7 @@ end
780
835
  provider: presence(r.params["provider"]),
781
836
  thinking: presence(r.params["thinking"]))
782
837
  dispatch_send_message(agent: agent, session_id: session_id, message: message)
783
- # Optimistic echo of the just-sent message (§11 A1): survives the redirect
838
+ # Optimistic echo of the just-sent message: survives the redirect
784
839
  # as a one-shot flash, rendered as a user bubble on the next GET.
785
840
  flash["sent_message"] = message unless message.empty?
786
841
  r.redirect(playground_path(agent, session_id))
@@ -830,7 +885,7 @@ end
830
885
  ]
831
886
  end
832
887
 
833
- # NAV_ICONS + nav_icon moved to Studio::NavIcons (§11 B6).
888
+ # NAV_ICONS + nav_icon moved to Studio::NavIcons.
834
889
 
835
890
  def authenticated? = session["auth"] == true
836
891
 
@@ -914,7 +969,7 @@ end
914
969
 
915
970
  # A stored message's content for display: strings as-is; structured payloads
916
971
  # as pretty JSON. NEVER Ruby #inspect (session.erb:45 leaked hashrockets to the
917
- # operator). §11 A2.
972
+ # operator)..
918
973
  def message_content(content)
919
974
  return content.to_s if content.is_a?(String)
920
975
 
@@ -923,6 +978,29 @@ end
923
978
  content.to_s
924
979
  end
925
980
 
981
+ # An activation label from the context trace, as the operator reads it:
982
+ # "gift-concierge · trigger:presente". The REASON is the point of the card — a
983
+ # bare list of names answers "was something injected", never "which one did I
984
+ # trigger". A label with no reason (a plugin-supplied body) shows just the name.
985
+ def skill_label(label)
986
+ return label.to_s unless label.is_a?(Hash)
987
+
988
+ name = label["name"].to_s
989
+ reason = label["reason"].to_s
990
+ reason.empty? ? name : "#{name} · #{reason}"
991
+ end
992
+
993
+ # One word for the whole turn, for the collapsed summary: the shared reason when
994
+ # every body arrived the same way, "mixed" when they did not. A turn CAN mix (the
995
+ # agent's eager set plus what this message triggered), which is exactly why the
996
+ # single per-turn `mode` this replaced was a lie waiting to happen.
997
+ def skill_mode(labels)
998
+ kinds = Array(labels).map { |l| (l.is_a?(Hash) ? l["reason"].to_s : "").split(":").first }.uniq
999
+ return "context" if kinds.empty? || kinds.any?(&:nil?) || kinds.include?("")
1000
+
1001
+ kinds.length == 1 ? kinds.first : "mixed"
1002
+ end
1003
+
926
1004
  # ISO8601 → "HH:MM" for a transcript timestamp; "" when unparseable/absent.
927
1005
  def short_time(iso)
928
1006
  Time.parse(iso.to_s).strftime("%H:%M")
@@ -981,11 +1059,11 @@ end
981
1059
  end
982
1060
  @all_skills = insika[:skill_catalog]&.all || []
983
1061
  @agent_skills = @agent.skills.nil? ? nil : Array(@agent.skills).map(&:to_s)
984
- # v2 (§10) config surfaces: generation params + model fence. AgentProfile.build
1062
+ # v2 config surfaces: generation params + model fence. AgentProfile.build
985
1063
  # string-keys these hashes, so the form helpers read plain string keys.
986
1064
  @params = @agent.params
987
1065
  @model_policy_allow = model_policy_allow(@agent)
988
- # guardrails config (RFC-0009); nil when the agent never configured any.
1066
+ # guardrails config; nil when the agent never configured any.
989
1067
  @guardrails = @agent.guardrails || {}
990
1068
  mem = insika[:memory_store]
991
1069
  @facts = mem ? mem.facts(tenant: id) : []
@@ -1003,7 +1081,7 @@ end
1003
1081
  end
1004
1082
 
1005
1083
  # Renders the reasoning <select> shared by the agent config, settings and
1006
- # playground (§10, 4-layer). `blank_label` names the empty option — the
1084
+ # playground (4-layer). `blank_label` names the empty option — the
1007
1085
  # "inherit the broader layer" / provider-default choice. Values come from a
1008
1086
  # fixed constant (safe to emit); `current` is only compared, never output.
1009
1087
  def thinking_select(name, current, blank_label)
@@ -1047,21 +1125,34 @@ end
1047
1125
 
1048
1126
  # Config patch from the form (native types: memory bool, limits int).
1049
1127
  # Preserves the existing limits, overwriting only the form's fields.
1050
- # `model` is OPTIONAL as of v2 (§10): blank clears it, so the agent inherits
1128
+ # `model` is OPTIONAL as of v2: blank clears it, so the agent inherits
1051
1129
  # the platform `default_model` via the ModelResolver — the config form is the
1052
1130
  # place that surfaces that layering. params/model_policy: see the helpers below.
1053
1131
  # config_patch/guardrails_patch/guardrail_responses_patch/params_patch/
1054
- # model_policy_patch/coerce moved to Studio::Forms (§11 B6).
1132
+ # model_policy_patch/coerce moved to Studio::Forms.
1055
1133
 
1056
1134
  # --- Skills index --------------------------------------------------------
1057
1135
 
1058
1136
  # Master data for the Skills drill-down (list + authored badges + agents),
1059
1137
  # shared by every skill route so the master pane always renders. @selected
1060
1138
  # drives the detail pane (nil = none, "" = new, name = edit).
1061
- def load_skills_master
1062
- @skills = (insika[:skill_catalog]&.all || []).sort_by(&:name)
1063
- @stored = insika[:skill_store] ? insika[:skill_store].names : []
1139
+ # `agent` renders the AGENT's view of the catalog: its own version of each shared
1140
+ # skill, plus whatever is private to it. Absent = the shared catalog.
1141
+ def load_skills_master(agent: nil)
1142
+ catalog = insika[:skill_catalog]
1143
+ @skills = (catalog ? catalog.all(agent: agent) : []).sort_by(&:name)
1144
+ @stored = insika[:skill_store] ? insika[:skill_store].names(agent: agent) : []
1064
1145
  @agents = insika[:profile_source].all.sort_by(&:id)
1146
+ # Which agents specialized THIS skill — the availability grid shows it, so an
1147
+ # override is discoverable from the shared skill it overrides.
1148
+ @specialized = insika[:skill_store] ? specialized_by : {}
1149
+ end
1150
+
1151
+ # { skill name => [agent ids] } across every agent scope in the store.
1152
+ def specialized_by
1153
+ insika[:skill_store].agents.each_with_object({}) do |agent, acc|
1154
+ insika[:skill_store].names(agent: agent).each { |name| (acc[name] ||= []) << agent }
1155
+ end
1065
1156
  end
1066
1157
 
1067
1158
  def render_skills_index
@@ -1076,14 +1167,20 @@ end
1076
1167
  # Raw content for the editor: prefer the store (real SKILL.md), otherwise
1077
1168
  # reconstruct from what the catalog parsed (editing a disk skill
1078
1169
  # creates an override in the store — Store wins).
1079
- def skill_source(name)
1080
- raw = insika[:skill_store]&.get(name)
1170
+ def skill_source(name, agent: nil)
1171
+ raw = insika[:skill_store]&.get(name, agent: agent)
1081
1172
  return raw if raw
1082
1173
 
1083
- skill = insika[:skill_catalog]&.find(name)
1174
+ skill = insika[:skill_catalog]&.find(name, agent: agent)
1084
1175
  return new_skill_template(name) unless skill
1085
1176
 
1086
- "---\nname: #{skill.name}\ndescription: #{skill.description}\n---\n\n#{skill.body}\n"
1177
+ # The WHOLE frontmatter, not just name/description: this text seeds the
1178
+ # specialize action and any first edit of a disk skill, and an override that
1179
+ # silently drops `triggers:` turns the agent's deterministic activation off.
1180
+ fm = ["name: #{skill.name}", "description: #{skill.description}"]
1181
+ fm << "triggers: #{skill.triggers.join(', ')}" if Array(skill.triggers).any?
1182
+ fm << "companions: #{skill.companions.join(', ')}" if Array(skill.companions).any?
1183
+ "---\n#{fm.join("\n")}\n---\n\n#{skill.body}\n"
1087
1184
  end
1088
1185
 
1089
1186
  def new_skill_template(name = "my-skill")
@@ -1097,6 +1194,31 @@ end
1097
1194
  profile.skills.nil? || Array(profile.skills).map(&:to_s).include?(skill_name.to_s)
1098
1195
  end
1099
1196
 
1197
+ # Is this skill always-on for this agent? `skills_eager` is nil/false (none), true
1198
+ # (blanket) or a list — NOT allowlist semantics, so nil must read as false here.
1199
+ def skill_eager_for?(profile, skill_name)
1200
+ spec = profile.skills_eager
1201
+ return true if Insika::Coercion.truthy?(spec)
1202
+ return false if spec.nil? || spec == false
1203
+
1204
+ Array(spec).map(&:to_s).include?(skill_name.to_s)
1205
+ end
1206
+
1207
+ # The two "left intact" outcomes of :set_skill_agents: an agent whose allowlist (or
1208
+ # eager set) is "all" cannot have ONE name removed from it without materializing an
1209
+ # explicit list, which would be a destructive surprise. Say so instead of silently
1210
+ # doing nothing.
1211
+ def note_skipped(result)
1212
+ return unless result
1213
+
1214
+ parts = []
1215
+ skipped = Array(result[:skipped_all])
1216
+ eager = Array(result[:skipped_eager_all])
1217
+ parts << "#{skipped.size} agent(s) with 'all' skills were left intact" if skipped.any?
1218
+ parts << "#{eager.size} agent(s) with blanket eager were left intact" if eager.any?
1219
+ flash["notice"] = "#{flash['notice']} — #{parts.join(', ')}." if parts.any?
1220
+ end
1221
+
1100
1222
  # --- Tools matrix --------------------------------------------------------
1101
1223
 
1102
1224
  def render_tools_matrix
@@ -1137,7 +1259,7 @@ end
1137
1259
  tools.count { |t| allow.include?(t.name.to_s) && !tool_denied_for?(profile, t.name) }
1138
1260
  end
1139
1261
 
1140
- # --- Data-defined tool authoring (Phase 5) -------------------------------
1262
+ # Data-defined tool authoring -------------------------------
1141
1263
 
1142
1264
  def render_tool_edit(name:, tool:)
1143
1265
  @tool_name = name
@@ -1164,7 +1286,7 @@ end
1164
1286
  }
1165
1287
  end
1166
1288
 
1167
- # tool_patch/parse_parameters moved to Studio::Forms (§11 B6).
1289
+ # tool_patch/parse_parameters moved to Studio::Forms.
1168
1290
 
1169
1291
  # Inverse of parse_parameters: the stored params -> text for the textarea. A schema
1170
1292
  # the flat sugar can express round-trips as pipe lines (the friendly form); anything
@@ -1333,10 +1455,10 @@ end
1333
1455
  view("settings")
1334
1456
  end
1335
1457
 
1336
- # Settings patch from the form. streaming/compaction are bool
1337
- # (checkbox); the timeouts are integers; compaction.keep_last integer. Only what
1338
- # came in the form enters the patch (the rest and the defaults are preserved in the store).
1339
- # settings_patch/model_defaults_patch/provider_patch moved to Studio::Forms (§11 B6).
1458
+ # Settings patch from the form. streaming is a bool (checkbox); the timeouts
1459
+ # are integers. Only what came in the form enters the patch (the rest and
1460
+ # the defaults are preserved in the store).
1461
+ # settings_patch/model_defaults_patch/provider_patch moved to Studio::Forms.
1340
1462
 
1341
1463
  # --- MCP -------------------------------------------------------
1342
1464
 
@@ -1345,7 +1467,7 @@ end
1345
1467
  view("mcp")
1346
1468
  end
1347
1469
 
1348
- # mcp_patch moved to Studio::Forms (§11 B6).
1470
+ # mcp_patch moved to Studio::Forms.
1349
1471
 
1350
1472
  # --- System-files ----------------------------------------------
1351
1473
 
@@ -1365,7 +1487,7 @@ end
1365
1487
  view("chats")
1366
1488
  end
1367
1489
 
1368
- # --- Tasks & Approvals (§12 G5) --------------------------------
1490
+ # Tasks & Approvals --------------------------------
1369
1491
 
1370
1492
  # Task list, most-recently-updated first. Empty-state if no store was injected.
1371
1493
  def render_tasks
@@ -1392,7 +1514,7 @@ end
1392
1514
  view("approvals")
1393
1515
  end
1394
1516
 
1395
- # --- Evals (RFC-0008 §3.1) -------------------------------------
1517
+ # Evals -------------------------------------
1396
1518
 
1397
1519
  # The stored cases, grouped by agent, plus the one being edited (?id=). Cases whose
1398
1520
  # stored mapping no longer validates are listed separately: a broken case must be
@@ -1417,7 +1539,7 @@ def golden_yaml(golden)
1417
1539
  YAML.dump(h.merge("expect" => golden.expect))
1418
1540
  end
1419
1541
 
1420
- # --- Refinement (RFC-0013 phase A) -----------------------------
1542
+ # Refinement -----------------------------
1421
1543
 
1422
1544
  # The agent's latest failure report + its run history. Empty-state when no store
1423
1545
  # was injected or the agent has never been run — the page is the invitation to
@@ -1484,7 +1606,7 @@ end
1484
1606
 
1485
1607
  def task_path(id) = "/studio/tasks/#{Rack::Utils.escape(id.to_s)}"
1486
1608
 
1487
- # parse_kv_lines moved to Studio::Forms (§11 B6).
1609
+ # parse_kv_lines moved to Studio::Forms.
1488
1610
 
1489
1611
  # CSV/whitespace -> [String], blanks dropped.
1490
1612
  def split_list(str)