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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Studio
4
- # Form → command-payload parsing for the Studio App (§11 B6). A mixin, NOT a
4
+ # Form → command-payload parsing for the Studio App. A mixin, NOT a
5
5
  # standalone object: `include`d into App so every method keeps `self` == the
6
6
  # App instance and reaches its request helpers (`presence`, `split_list`) and
7
7
  # per-request state (`@agent`) exactly as before. Pure extraction — the bodies
@@ -15,7 +15,7 @@ module Studio
15
15
  v = presence(r.params[field])
16
16
  limits[field.to_sym] = Integer(v) if v
17
17
  end
18
- # Per-agent edge overrides (item 33). Unlike the timeouts (always present via
18
+ # Per-agent edge overrides. Unlike the timeouts (always present via
19
19
  # DEFAULT_LIMITS), these are OPT-IN keys: blank DELETES the override (inherit
20
20
  # the platform edge settings); 0 explicitly disables for this agent.
21
21
  %w[chat_rate_limit agent_token_ceiling].each do |field|
@@ -38,7 +38,7 @@ module Studio
38
38
  }
39
39
  end
40
40
 
41
- # Guardrails config from the form (RFC-0009). The config form OWNS these fields,
41
+ # Guardrails config from the form. The config form OWNS these fields,
42
42
  # so the patch reflects the whole guardrails state. String values round-trip
43
43
  # cleanly through the JSON store; Safety::Config normalizes on read. A blank
44
44
  # moderator drops the key (deterministic only).
@@ -54,7 +54,7 @@ module Studio
54
54
  out
55
55
  end
56
56
 
57
- # Per-category safe-reply overrides (RFC-0009 §7, config over convention). Only
57
+ # Per-category safe-reply overrides (config over convention). Only
58
58
  # the non-blank fields are persisted; Safety::Config normalizes on read.
59
59
  def guardrail_responses_patch(r)
60
60
  %w[default injection sexual abuse escalate].each_with_object({}) do |cat, acc|
@@ -62,7 +62,7 @@ module Studio
62
62
  end
63
63
  end
64
64
 
65
- # Per-agent generation params (v2, §10). The config form OWNS these fields, so
65
+ # Per-agent generation params. The config form OWNS these fields, so
66
66
  # the patch reflects the whole form state: a blank field drops the key (and an
67
67
  # all-blank form clears params to {}). temperature/max_tokens MUST be Numeric —
68
68
  # ModelSelection#apply_params guards on `numeric?` and silently skips a String —
@@ -79,7 +79,7 @@ module Studio
79
79
  out
80
80
  end
81
81
 
82
- # Per-agent model fence (v2, §10): { "allow" => [refs] } where a ref is
82
+ # Per-agent model fence: { "allow" => [refs] } where a ref is
83
83
  # "provider/model", "provider/*" or "model". Blank textarea -> nil (NO fence,
84
84
  # all models — parity). Enforced on the RESOLVED model by the ModelResolver,
85
85
  # so a per-chat pin can never escape it.
@@ -101,7 +101,7 @@ module Studio
101
101
 
102
102
  # Fields a tool CAN carry that this form does not render. The store REPLACES the
103
103
  # record on write, so anything missing here is erased — a save that only fixed a
104
- # typo in the description would silently drop them (the class of bug §12 already
104
+ # typo in the description would silently drop them (the class of bug already
105
105
  # paid for once). `stored` carries them through untouched.
106
106
  UNEDITED_TOOL_FIELDS = %w[group tags halt_when].freeze
107
107
 
@@ -166,22 +166,17 @@ module Studio
166
166
 
167
167
  def settings_patch(r)
168
168
  patch = {
169
- "streaming" => r.params["streaming"] == "1",
170
- "compaction" => {
171
- "enabled" => r.params["compaction_enabled"] == "1"
172
- }
169
+ "streaming" => r.params["streaming"] == "1"
173
170
  }
174
171
  { "request_timeout" => "request_timeout", "max_retries" => "max_retries",
175
172
  "turn_timeout" => "turn_timeout", "tool_timeout" => "tool_timeout" }.each_key do |f|
176
173
  v = presence(r.params[f])
177
174
  patch[f] = Integer(v) if v
178
175
  end
179
- kl = presence(r.params["keep_last"])
180
- patch["compaction"]["keep_last"] = Integer(kl) if kl
181
176
  patch
182
177
  end
183
178
 
184
- # Edge limits (item 33 / §12 G7) — the platform rate-limit/cost layer, saved
179
+ # Edge limits — the platform rate-limit/cost layer, saved
185
180
  # from its OWN form (a sub-resource, like models). The limit fields write nil
186
181
  # when blank (off — the EdgeLimiter reads nil/0 as off); the windows fall back
187
182
  # to the built-in defaults when cleared (the limiter guards non-positive).
@@ -209,7 +204,7 @@ module Studio
209
204
  { case: parsed }
210
205
  end
211
206
 
212
- # Evals graders (RFC-0013 §3.9). `judges` is one `provider/model` per LINE — a
207
+ # Evals graders. `judges` is one `provider/model` per LINE — a
213
208
  # textarea, because the SIZE of the panel is the point and a fixed pair of fields
214
209
  # would cap it at two. A bare `model` (no slash) is valid: the provider is then
215
210
  # inferred by RubyLLM, same as everywhere else.
@@ -258,7 +253,7 @@ module Studio
258
253
  raise Insika::ValidationError, "#{field} must be an integer (got #{raw.inspect})"
259
254
  end
260
255
 
261
- # LLM config v2 (§10) — the platform model layer, saved from its OWN form (a
256
+ # LLM config v2 — the platform model layer, saved from its OWN form (a
262
257
  # sub-resource, like providers) so the general-settings save never touches it and
263
258
  # vice-versa. The scalar refs are always present (blank -> nil, which the
264
259
  # deep-merge writes and the ModelResolver reads as "no platform default");
@@ -270,12 +265,12 @@ module Studio
270
265
  "default_provider" => presence(r.params["default_provider"]),
271
266
  "utility_model" => presence(r.params["utility_model"]),
272
267
  "fallback_models" => split_list(r.params["fallback_models"]),
273
- # GLOBAL reasoning default (§10, 4-layer). Blank -> nil = provider default.
268
+ # GLOBAL reasoning default (4-layer). Blank -> nil = provider default.
274
269
  "thinking" => presence(r.params["thinking"])
275
270
  }
276
271
  end
277
272
 
278
- # Per-model reasoning defaults (§10, the per-model layer). One line per model:
273
+ # Per-model reasoning defaults (the per-model layer). One line per model:
279
274
  # <provider/model | model> | <off|on|low|medium|high>
280
275
  # CSP forbids JS for dynamic rows, so a textarea of lines is the honest path
281
276
  # (same idiom as the MCP env / tool params). A blank effort -> {} for that ref
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Studio
4
- # Nav SVG icons for the Studio App (§11 B6). A mixin included into App: the
4
+ # Nav SVG icons for the Studio App. A mixin included into App: the
5
5
  # `nav_icon` view helper and its icon table, moved verbatim out of app.rb.
6
6
  # Inline SVG is CSP-safe (an HTML element, not an external resource).
7
7
  module NavIcons
@@ -1,4 +1,4 @@
1
- <%# Shared transcript bubble/tool-card (§11 A1/A2) — one visual language for the
1
+ <%# Shared transcript bubble/tool-card — one visual language for the
2
2
  playground history, the optimistic echo and the read-only session viewer.
3
3
  Locals: m = stored message hash {"role","content","at"}; prev = the message before
4
4
  it in the thread, or nil (used only to spot a steered message). `content` may be a
@@ -22,7 +22,7 @@
22
22
  when it is NOT the natural producer for the role — otherwise every bubble
23
23
  would carry a label that says nothing. %>
24
24
  <% origin = Insika::MessageOrigin.origin_of(m) unless Insika::MessageOrigin.customer?(m) || Insika::MessageOrigin.agent?(m) %>
25
- <%# STEERED (RFC-0015): a customer message sitting right after a tool result arrived
25
+ <%# STEERED: a customer message sitting right after a tool result arrived
26
26
  while the turn was running and was appended at a tool-batch boundary. Derived
27
27
  from position, not from a field — a steered message declares no origin, and it
28
28
  is right not to: a person typed it. Nothing else in the engine ever puts a user
@@ -69,7 +69,7 @@
69
69
  </label>
70
70
 
71
71
  <fieldset class="subgroup">
72
- <legend>guardrails <span class="muted">— content safety (RFC-0009)</span></legend>
72
+ <legend>guardrails <span class="muted">— content safety</span></legend>
73
73
  <label class="check">
74
74
  <input type="checkbox" name="guardrail_input" value="1"<%== " checked" if guardrail_field(@guardrails, :input, true) %>>
75
75
  input guardrail <span class="muted">— block injection / abuse / inappropriate before the LLM</span>
@@ -88,7 +88,7 @@
88
88
  </select>
89
89
  </label>
90
90
  <label>LLM moderator <span class="muted">(model ref, or <code>on</code> for the platform utility_model; blank = deterministic only)</span>
91
- <input type="text" name="guardrail_moderator" value="<%= guardrail_field(@guardrails, :moderator, "") %>" placeholder="deepseek/deepseek-chat">
91
+ <input type="text" name="guardrail_moderator" value="<%= guardrail_field(@guardrails, :moderator, "") %>" placeholder="deepseek/deepseek-v4-flash">
92
92
  </label>
93
93
  </div>
94
94
  <details class="newfile">
@@ -48,7 +48,7 @@
48
48
  <input type="text" name="id" placeholder="bia" required>
49
49
  </label>
50
50
  <label>model
51
- <input type="text" name="model" placeholder="deepseek-chat" required>
51
+ <input type="text" name="model" placeholder="deepseek-v4-flash" required>
52
52
  </label>
53
53
  </div>
54
54
  <label>provider <span class="muted">(optional)</span>
@@ -39,7 +39,7 @@
39
39
  </div>
40
40
  <% end %>
41
41
 
42
- <%# RFC-0013 §3.6 — the review IS the product. A gated proposal shows the exact
42
+ <%# The review IS the product. A gated proposal shows the exact
43
43
  edit, what it claims to fix, and how it scored against the accepted baseline,
44
44
  because approving is a decision and a decision needs those three things. %>
45
45
  <% if @proposal %>
@@ -51,7 +51,7 @@
51
51
  <span class="mono"><%= @proposal.gate["passed_cases"] %>/<%= @proposal.gate["cases"] %> case(s)</span>
52
52
  <span class="muted">no regression against <%= @proposal.gate["baseline_cases"] %> baselined case(s)</span>
53
53
  <span class="muted">· proposed by <span class="mono"><%= cand["proposer"] %></span></span>
54
- <%# RFC-0013 §3.9 — when a PANEL ran, the operator is looking at the winner of
54
+ <%# When a PANEL ran, the operator is looking at the winner of
55
55
  several, and which ones it beat is part of the decision. %>
56
56
  <% if @proposal.panel.size > 1 %>
57
57
  <span class="muted">· best of <%= @proposal.panel.size %> candidate(s)</span>
@@ -125,7 +125,7 @@
125
125
  <% else %>
126
126
  <h2>Findings<span class="sub"><%= @run.findings.size %> · <%= @run.status %><% if @run.excluded.to_i.positive? %> · <%= @run.excluded %> turn(s) excluded<% end %> · <%= @run.finished_at || @run.started_at %></span></h2>
127
127
 
128
- <%# RFC-0013 §3.4 — the model writes the candidate, the gate scores it. Offered on
128
+ <%# The model writes the candidate, the gate scores it. Offered on
129
129
  a finished report and only when nothing is already awaiting an answer: a second
130
130
  proposal while one is pending would ask the operator to review two edits to the
131
131
  same file that were never scored together. %>
@@ -147,7 +147,7 @@
147
147
  </div>
148
148
  <% end %>
149
149
 
150
- <%# RFC-0013 §3.6 — `mode: auto_apply` changed this agent's files with nobody in
150
+ <%# `mode: auto_apply` changed this agent's files with nobody in
151
151
  the loop, so the page owes the operator exactly three things: what changed, why,
152
152
  and where to undo it. The write versioned, so History IS the undo. %>
153
153
  <% if @run.status == :applied && (@run.decision || {})["by"] == "auto_apply" %>
@@ -12,6 +12,57 @@
12
12
  <a class="btn" href="/studio/playground?session_id=<%= Rack::Utils.escape(@session.id) %>">Continue in playground →</a>
13
13
  </header>
14
14
 
15
+ <% ctx_traces = @context_traces || [] %>
16
+ <% unless ctx_traces.empty? %>
17
+ <div class="card tooltrace">
18
+ <div class="trace-head">
19
+ <h2>Context</h2>
20
+ <span class="muted">tokens by category per turn (estimated) — no content stored</span>
21
+ </div>
22
+ <% ctx_traces.each do |c| %>
23
+ <div class="trace-turn">
24
+ <div class="turn-label">turn <%= c["turn"] %> · <%= c["at"] %></div>
25
+ <%# Skills injected by context (triggers/skills_eager) leave NO tool call in
26
+ the transcript, so this card is the only record that they were active.
27
+ The names live in the body, but the count belongs in the SUMMARY: the
28
+ card is collapsed, and "was the skill on?" is the question an operator
29
+ asks on every single turn. %>
30
+ <% skills = Array((c["categories"] || {}).dig("skilltrigger", "labels")).uniq %>
31
+ <details class="trace-item ok">
32
+ <summary>
33
+ <span class="badge"><%= c["used"] %> / <%= c["cap"] %></span>
34
+ <strong>context</strong>
35
+ <% unless skills.empty? %>
36
+ <span class="badge">skills <%= skills.length %></span>
37
+ <% end %>
38
+ <% unless Array(c["evicted"]).empty? %>
39
+ <span class="lat">evicted: <%= Array(c["evicted"]).join(", ") %></span>
40
+ <% end %>
41
+ </summary>
42
+ <% (c["categories"] || {}).sort_by { |_k, v| -(v["tokens"] || 0) }.each do |name, cat| %>
43
+ <div class="kv"><span><%= name %></span>
44
+ <div><%= cat["tokens"] %> tokens · <%= cat["fragments"] %> fragment(s)<%== " · pinned" if (cat["pinned"] || 0).positive? %>
45
+ <%# WHICH skills the turn injected AND WHY — the only after-the-fact %>
46
+ <%# record of activation once the bodies arrive by context instead of a %>
47
+ <%# tool call. The reason is the half the operator was missing: a name %>
48
+ <%# alone does not say whether they triggered it or the agent always has it. %>
49
+ <% labels = Array(cat["labels"]).uniq %>
50
+ <% unless labels.empty? %>
51
+ <br><span class="lat"><%= labels.map { |l| skill_label(l) }.join(", ") %></span>
52
+ <% end %>
53
+ </div>
54
+ </div>
55
+ <% end %>
56
+ <% tools = c["tools"] || {} %>
57
+ <div class="kv"><span>tools</span>
58
+ <div><%= tools["tokens"] %> tokens · <%= tools["count"] %> tool schema(s)</div>
59
+ </div>
60
+ </details>
61
+ </div>
62
+ <% end %>
63
+ </div>
64
+ <% end %>
65
+
15
66
  <% traces = @tool_traces || {} %>
16
67
  <% unless traces.empty? %>
17
68
  <div class="card tooltrace">
@@ -54,9 +105,33 @@
54
105
  <div class="empty">This session has no messages.</div>
55
106
  <% else %>
56
107
  <div class="thread">
57
- <%# `prev` is what makes a STEERED message visible: a customer message that lands
58
- right after a tool result arrived mid-run (RFC-0015). See _message.erb. %>
59
- <% Array(@session.messages).each_with_index do |m, i| %><%== render("_message", locals: { m: m, prev: i.positive? ? @session.messages[i - 1] : nil }) %><% end %>
108
+ <%# A skill loaded by the model shows up here on its own: load_skill is a tool,
109
+ so its body is a persisted `role: tool` message. A skill injected by CONTEXT
110
+ (triggers/skills_eager) is part of the system prompt — never a message — so
111
+ the thread had no trace of it at all. Synthesized from the context trace so
112
+ both activation paths read the same way in the transcript.
113
+
114
+ Placed by TIME, not by counting turns: every message of a turn is persisted
115
+ together at the end of it, and the context stage always runs before that, so
116
+ each entry lands at the top of its own turn. Ordinal counting would drift on
117
+ a steered message (one that joins a turn already running). A message with no
118
+ `at` (older sessions) simply flushes nothing. %>
119
+ <% pending = (@context_traces || [])
120
+ .select { |c| Array((c["categories"] || {}).dig("skilltrigger", "labels")).any? }
121
+ .sort_by { |c| c["at"].to_s } %>
122
+ <% Array(@session.messages).each_with_index do |m, i| %>
123
+ <% while (entry = pending.first) && !m["at"].to_s.empty? && entry["at"].to_s <= m["at"].to_s %>
124
+ <% pending.shift %>
125
+ <% labels = Array(entry["categories"]["skilltrigger"]["labels"]).uniq %>
126
+ <details class="toolcard skill">
127
+ <summary><span class="arrow">↑</span><strong>skills · <%= skill_mode(labels) %> (<%= labels.length %>)</strong><span class="at"><%= entry["at"] %></span></summary>
128
+ <pre><%= labels.map { |l| skill_label(l) }.join("\n") %></pre>
129
+ </details>
130
+ <% end %>
131
+ <%# `prev` is what makes a STEERED message visible: a customer message that lands
132
+ right after a tool result arrived mid-run. See _message.erb. %>
133
+ <%== render("_message", locals: { m: m, prev: i.positive? ? @session.messages[i - 1] : nil }) %>
134
+ <% end %>
60
135
  </div>
61
136
  <% end %>
62
137
  </div>
@@ -1,7 +1,7 @@
1
1
  <%# Settings — drill-down (T2): section list | selected section's form(s).
2
2
  @section (general|models|llm) comes from ?s= and drives the detail. %>
3
3
  <% sections = [
4
- ["general", "General", "streaming, timeouts, compaction"],
4
+ ["general", "General", "streaming, timeouts"],
5
5
  ["models", "Model defaults", "platform model layer (Chat › Agent › platform)"],
6
6
  ["edge", "Edge limits", "rate-limit per chat, token ceiling per agent"],
7
7
  ["evals", "Evals", "who grades a rubric, and how a panel agrees"],
@@ -27,7 +27,7 @@
27
27
  <button type="submit" form="set-general" class="btn primary" data-turbo-submits-with="Saving…">Save</button>
28
28
  </div>
29
29
  <div class="drill-pane-body">
30
- <p class="muted">Streaming, timeouts and compaction — saved via <code>update_settings</code> (durable, applies next turn).</p>
30
+ <p class="muted">Streaming and timeouts — saved via <code>update_settings</code> (durable, applies next turn).</p>
31
31
  <form id="set-general" method="post" action="/studio/settings">
32
32
  <%== csrf_tag %>
33
33
  <label class="check">
@@ -40,11 +40,6 @@
40
40
  <label>turn_timeout (s)<input type="text" name="turn_timeout" value="<%= @settings["turn_timeout"] %>" inputmode="numeric"></label>
41
41
  <label>tool_timeout (s)<input type="text" name="tool_timeout" value="<%= @settings["tool_timeout"] %>" inputmode="numeric"></label>
42
42
  </div>
43
- <label class="check">
44
- <input type="checkbox" name="compaction_enabled" value="1"<%== " checked" if @settings.dig("compaction", "enabled") %>>
45
- context compaction
46
- </label>
47
- <label>compaction.keep_last (messages)<input type="text" name="keep_last" value="<%= @settings.dig("compaction", "keep_last") %>" inputmode="numeric"></label>
48
43
  </form>
49
44
  </div>
50
45
 
@@ -58,11 +53,11 @@
58
53
  <form id="set-models" method="post" action="/studio/settings/models">
59
54
  <%== csrf_tag %>
60
55
  <div class="grid-2">
61
- <label>default_model <span class="muted">(agents without their own model)</span><input type="text" name="default_model" value="<%= @settings["default_model"] %>" placeholder="deepseek-chat"></label>
56
+ <label>default_model <span class="muted">(agents without their own model)</span><input type="text" name="default_model" value="<%= @settings["default_model"] %>" placeholder="deepseek-v4-flash"></label>
62
57
  <label>default_provider <span class="muted">(optional)</span><input type="text" name="default_provider" value="<%= @settings["default_provider"] %>" placeholder="deepseek"></label>
63
58
  </div>
64
- <label>fallback_models <span class="muted">(ordered chain; <code>provider/model</code> or <code>model</code>, comma or newline)</span><input type="text" name="fallback_models" value="<%= Array(@settings["fallback_models"]).join(", ") %>" placeholder="deepseek/deepseek-chat, openai/gpt-4o-mini"></label>
65
- <label>utility_model <span class="muted">(cheap internal tasks — titles, distillation)</span><input type="text" name="utility_model" value="<%= @settings["utility_model"] %>" placeholder="deepseek-chat"></label>
59
+ <label>fallback_models <span class="muted">(ordered chain; <code>provider/model</code> or <code>model</code>, comma or newline)</span><input type="text" name="fallback_models" value="<%= Array(@settings["fallback_models"]).join(", ") %>" placeholder="deepseek/deepseek-v4-flash, openai/gpt-4o-mini"></label>
60
+ <label>utility_model <span class="muted">(cheap internal tasks — titles, distillation)</span><input type="text" name="utility_model" value="<%= @settings["utility_model"] %>" placeholder="deepseek-v4-flash"></label>
66
61
  <label>reasoning <span class="muted">(global default — off/on/effort; blank = provider default)</span>
67
62
  <%== thinking_select("thinking", @settings["thinking"], "provider default") %>
68
63
  </label>
@@ -107,7 +102,7 @@
107
102
  <p class="muted">Who grades a <a href="/studio/evals">case</a>'s rubric. <strong>One model per line</strong> as <code>provider/model</code> — several lines make a PANEL, and two different models disagreeing is the signal worth having (sampling one model N times mostly measures its own variance). Empty = deterministic assertions only, and rubric'd cases read as <code>judge_pending</code>.</p>
108
103
  <form id="set-evals" method="post" action="/studio/settings/evals">
109
104
  <%== csrf_tag %>
110
- <label>judges<textarea name="judges" rows="4" spellcheck="false" placeholder="deepseek/deepseek-chat&#10;openai/gpt-5-mini"><%= Array(@settings.dig("evals", "judges")).map { |j| [j["provider"], j["model"]].compact.reject(&:empty?).join("/") }.join("\n") %></textarea></label>
105
+ <label>judges<textarea name="judges" rows="4" spellcheck="false" placeholder="deepseek/deepseek-v4-flash&#10;openai/gpt-5-mini"><%= Array(@settings.dig("evals", "judges")).map { |j| [j["provider"], j["model"]].compact.reject(&:empty?).join("/") }.join("\n") %></textarea></label>
111
106
  <div class="grid-2">
112
107
  <label>aggregate <span class="muted">(how the panel's scores combine)</span>
113
108
  <select name="aggregate">
@@ -147,7 +142,7 @@
147
142
  <label>auth_header<input type="text" name="auth_header" value="<%= p["auth_header"] %>" placeholder="Authorization"></label>
148
143
  </div>
149
144
  <label>api_key<input type="password" name="api_key" value="<%= p["api_key"] %>" autocomplete="off"></label>
150
- <label>models <span class="muted">(comma or newline)</span><input type="text" name="models" value="<%= Array(p["models"]).join(", ") %>" placeholder="deepseek-chat, deepseek-reasoner"></label>
145
+ <label>models <span class="muted">(comma or newline)</span><input type="text" name="models" value="<%= Array(p["models"]).join(", ") %>" placeholder="deepseek-v4-flash, deepseek-reasoner"></label>
151
146
  <div class="actions"><button type="submit" class="primary" data-turbo-submits-with="Saving…">Save provider</button></div>
152
147
  </form>
153
148
  </div>
@@ -1,6 +1,9 @@
1
1
  <%# Skills — drill-down (T2): master list | detail editor + availability.
2
2
  Every skill route renders THIS view (index / new / :name); @selected drives
3
- which skill is open (nil = none, "" = new). %>
3
+ which skill is open (nil = none, "" = new). @scope_agent set = editing THAT
4
+ agent's own version of the skill, not the shared one. %>
5
+ <% scope = @scope_agent %>
6
+ <% form_action = scope ? "/studio/agents/#{Rack::Utils.escape(scope)}/skills/#{Rack::Utils.escape(@selected.to_s)}" : "/studio/skills" %>
4
7
  <div class="drill" data-controller="list-filter">
5
8
  <aside class="drill-pane drill-master">
6
9
  <div class="drill-pane-head">
@@ -36,15 +39,30 @@
36
39
  <% new = @selected == "" %>
37
40
  <div class="drill-pane-head">
38
41
  <div class="title">
39
- <nav class="crumbs"><a href="/studio/skills">skills</a> / <span><%= new ? "new" : @selected %></span></nav>
40
- <strong class="mono"><%= new ? "New skill" : @selected %></strong>
42
+ <nav class="crumbs"><a href="/studio/skills">skills</a> / <span><%= new ? "new" : @selected %></span><% if scope %> / <span>for <%= scope %></span><% end %></nav>
43
+ <strong class="mono"><%= new ? "New skill" : @selected %><% if scope %> <span class="pill warn">specialized for <%= scope %></span><% end %></strong>
41
44
  </div>
42
45
  <button type="submit" form="skill-form" class="btn primary" data-turbo-submits-with="Saving…">Save skill</button>
43
46
  </div>
44
47
  <div class="drill-pane-body">
45
- <form id="skill-form" method="post" action="/studio/skills" data-controller="dirty-guard">
48
+ <%# The specialization is one MORE record, not a fork: the name stays the bare
49
+ shared name everywhere the model and the operator see it, and deleting the
50
+ override falls back to the shared body. %>
51
+ <% if scope %>
52
+ <div class="drill-section">
53
+ <p class="muted">This is <strong><%= scope %></strong>'s own version of <code><%= @selected %></code>. Every
54
+ other agent keeps the <a href="/studio/skills/<%= Rack::Utils.escape(@selected.to_s) %>">shared skill</a>,
55
+ and the name does not change — the allowlist, <code>&lt;available_skills&gt;</code> and
56
+ <code>load_skill</code> all still say <code><%= @selected %></code>.</p>
57
+ <form method="post" action="/studio/agents/<%= Rack::Utils.escape(scope) %>/skills/<%= Rack::Utils.escape(@selected.to_s) %>/delete">
58
+ <%== csrf_tag %>
59
+ <div class="actions"><button type="submit" data-turbo-confirm="Remove this specialization? <%= scope %> falls back to the shared skill.">Stop specializing</button></div>
60
+ </form>
61
+ </div>
62
+ <% end %>
63
+ <form id="skill-form" method="post" action="<%= form_action %>" data-controller="dirty-guard">
46
64
  <%== csrf_tag %>
47
- <% unless new %><input type="hidden" name="name" value="<%= @selected %>"><% end %>
65
+ <% if !new && !scope %><input type="hidden" name="name" value="<%= @selected %>"><% end %>
48
66
  <% if new %>
49
67
  <label>name <span class="muted">(canonical — same as <code>name:</code> in the frontmatter)</span>
50
68
  <input type="text" name="name" placeholder="my-skill" required>
@@ -56,29 +74,66 @@
56
74
  </div>
57
75
  </form>
58
76
 
59
- <% unless new %>
77
+ <% if !new && !scope %>
60
78
  <div class="drill-section">
61
79
  <div class="drill-section-head">
62
80
  <h3>Availability</h3>
63
- <span class="muted">which agents can <code>load_skill</code> this — an "all" allowlist is left untouched</span>
81
+ <span class="muted">per agent: <strong>allowed</strong> = can <code>load_skill</code> it; <strong>always on</strong> = the body
82
+ is in the prompt every turn. Both are the AGENT's decision, so one shared skill can be
83
+ always-on for one and discretionary for another. An "all" allowlist is left untouched.</span>
64
84
  </div>
65
85
  <form method="post" action="/studio/skills/<%= Rack::Utils.escape(@selected) %>/agents">
66
86
  <%== csrf_tag %>
67
87
  <% if @agents.empty? %>
68
88
  <p class="muted">No agents configured.</p>
69
89
  <% else %>
70
- <div class="check-grid">
90
+ <div class="check-grid agent-grid">
71
91
  <% @agents.each do |a| %>
72
- <label class="check">
73
- <input type="checkbox" name="agent_ids[]" value="<%= a.id %>"<%== " checked" if skill_enabled_for?(a, @selected) %>>
74
- <span class="mono"><%= a.id %></span>
75
- </label>
92
+ <% own = Array((@specialized || {})[@selected]).include?(a.id) %>
93
+ <div class="agent-check">
94
+ <div class="agent-check-name">
95
+ <span class="mono"><%= a.id %></span>
96
+ <% if own %>
97
+ <a class="pill warn" href="/studio/agents/<%= Rack::Utils.escape(a.id) %>/skills/<%= Rack::Utils.escape(@selected) %>">own version</a>
98
+ <% end %>
99
+ </div>
100
+ <label class="check tool-check">
101
+ <input type="checkbox" name="agent_ids[]" value="<%= a.id %>"<%== " checked" if skill_enabled_for?(a, @selected) %>>
102
+ <span class="mono">allowed</span>
103
+ </label>
104
+ <label class="check tool-check">
105
+ <input type="checkbox" name="eager_ids[]" value="<%= a.id %>"<%== " checked" if skill_eager_for?(a, @selected) %>>
106
+ <span class="mono">always on</span>
107
+ </label>
108
+ </div>
76
109
  <% end %>
77
110
  </div>
78
111
  <div class="actions"><button type="submit" class="primary" data-turbo-submits-with="Applying…">Apply to agents</button></div>
79
112
  <% end %>
80
113
  </form>
81
114
  </div>
115
+
116
+ <%# Specializing is what keeps a shared skill shared: without it the only way
117
+ to give one agent a different version was to fork it under a new name and
118
+ edit the allowlist, which throws the sharing away. %>
119
+ <% others = @agents.reject { |a| Array((@specialized || {})[@selected]).include?(a.id) } %>
120
+ <% if others.any? %>
121
+ <div class="drill-section">
122
+ <div class="drill-section-head">
123
+ <h3>Specialize</h3>
124
+ <span class="muted">give ONE agent its own version of this skill, seeded from the body above — same name, no fork</span>
125
+ </div>
126
+ <form method="post" action="/studio/skills/<%= Rack::Utils.escape(@selected) %>/specialize">
127
+ <%== csrf_tag %>
128
+ <label>agent
129
+ <select name="agent_id" required>
130
+ <% others.each do |a| %><option value="<%= a.id %>"><%= a.id %></option><% end %>
131
+ </select>
132
+ </label>
133
+ <div class="actions"><button type="submit" data-turbo-submits-with="Creating…">Specialize for this agent</button></div>
134
+ </form>
135
+ </div>
136
+ <% end %>
82
137
  <% end %>
83
138
  </div>
84
139
  <% end %>
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Insika
4
- # Definition-time integrity of the subagent delegation graph (RFC-0010 §4.4,
5
- # item 21). A pure function over `{id => [child_ids]}`: detects CYCLES and
4
+ # Definition-time integrity of the subagent delegation graph (
5
+ # A pure function over `{id => [child_ids]}`: detects CYCLES and
6
6
  # computes the max delegation DEPTH, raising a typed SubagentError so the
7
7
  # authoring Command (CreateAgent/UpdateAgent) fails cleanly and boot refuses a
8
8
  # bad static set. Cycle + bounded depth are exactly Flue's definition-time
@@ -21,7 +21,7 @@ module Insika
21
21
 
22
22
  # Max children a single `spawn_subagents` fan-out may run — also the concurrency
23
23
  # bound (N concurrent LLM calls hit the provider rate limit + the per-agent token
24
- # ceiling of item 33). Override with INSIKA_SUBAGENT_FANOUT_CAP.
24
+ # ceiling of). Override with INSIKA_SUBAGENT_FANOUT_CAP.
25
25
  DEFAULT_FANOUT_CAP = 8
26
26
 
27
27
  module_function
@@ -9,7 +9,7 @@ module Insika
9
9
  # `await` as the cooperative SUSPENSION primitive. Cancellation/suspension only
10
10
  # at stage boundaries — never in the middle of an operation.
11
11
  class TaskActor
12
- # `user_message` is posted by `Executor#steer_into_running` (RFC-0015 §5.1) and
12
+ # `user_message` is posted by `Executor#steer_into_running` and
13
13
  # consumed by `SteerInjector` at a tool-batch boundary. `pause`/`resume` (operator),
14
14
  # `approval` (human-in-the-loop), `timeout`/`heartbeat`
15
15
  # (watchdog/liveness, observation).
@@ -61,14 +61,14 @@ module Insika
61
61
  # the flag).
62
62
  def pause_requested? = @pause_requested
63
63
 
64
- # RFC-0015 §5.2 — takes the steered messages and clears the buffer, WITHOUT
64
+ # takes the steered messages and clears the buffer, WITHOUT
65
65
  # observing anything else in the mailbox: whatever is not a `:user_message` is put
66
66
  # back, in the order it arrived.
67
67
  #
68
68
  # Why not `drain!`: this runs INSIDE RubyLLM's tool loop (a `after_message`
69
69
  # callback), and `drain!` raises on `:cancel`. Cancellation is only ever observed
70
70
  # at the Executor's own stage boundaries — that is what keeps a tool batch one
71
- # unit of work (RFC-0015 §6.4, and D7 for the same rule under `turn_timeout`).
71
+ # unit of work (and for the same rule under `turn_timeout`).
72
72
  # Injecting a message must not quietly become a new place a turn can die.
73
73
  def take_user_messages!
74
74
  @mailbox.size.times do
@@ -131,7 +131,7 @@ module Insika
131
131
  to_task(record)
132
132
  end
133
133
 
134
- # RFC-0015 §5.3 (`collect`): appends a fragment to a task's message while it is
134
+ # (`collect`): appends a fragment to a task's message while it is
135
135
  # still waiting at the door. -> Task.
136
136
  #
137
137
  # ONLY on :queued, and that guard is the whole safety of the feature: once a
@@ -4,7 +4,7 @@ require "json"
4
4
 
5
5
  module Insika
6
6
  module Telemetry
7
- # Estimated cost of a turn, from a rates table given as DATA (item 16 / P4).
7
+ # Estimated cost of a turn, from a rates table given as DATA.
8
8
  #
9
9
  # Insika never ships prices: they change weekly, differ per contract and per
10
10
  # region, and a stale table in the engine would be worse than no number at all.
@@ -13,11 +13,11 @@ module Insika
13
13
  #
14
14
  # Rates are **USD per million tokens** (the unit every provider publishes):
15
15
  #
16
- # { "deepseek-chat" => { "input" => 0.27, "output" => 1.10,
16
+ # { "deepseek-v4-flash" => { "input" => 0.27, "output" => 1.10,
17
17
  # "cached_input" => 0.07, "cache_write" => 0.34 } }
18
18
  #
19
19
  # A key matches the model id the provider reports, with or without the
20
- # `provider/` prefix (`deepseek/deepseek-chat` and `deepseek-chat` both hit the
20
+ # `provider/` prefix (`deepseek/deepseek-v4-flash` and `deepseek-v4-flash` both hit the
21
21
  # entry above). An UNKNOWN model -> nil: a missing price is not a zero cost, so
22
22
  # nothing is emitted and the dashboard shows a gap instead of a lie.
23
23
  #
@@ -12,7 +12,7 @@ module Insika
12
12
  # insika.data_tool), correlated by task_id. Latency is the span duration; the
13
13
  # agent/tenant/model/tokens/cost ride as ATTRIBUTES.
14
14
  #
15
- # METRICS (item 16 / P4): the SAME events also feed counters and histograms, so
15
+ # METRICS: the SAME events also feed counters and histograms, so
16
16
  # volume/latency/tokens/cost are chartable WITHOUT span aggregation (which not
17
17
  # every backend does, and none does cheaply at retention). Metric attributes are
18
18
  # a deliberate LOW-CARDINALITY subset of the span attributes — never task_id or
@@ -5,7 +5,7 @@ require_relative "telemetry/pricing"
5
5
  require_relative "telemetry/recorder"
6
6
 
7
7
  module Insika
8
- # OPT-IN observability (Phase 6): OTEL mounted at the edge, core untouched. Rides
8
+ # OPT-IN observability: OTEL mounted at the edge, core untouched. Rides
9
9
  # the Event Stream — the Recorder consumes the events and emits spans and metrics.
10
10
  # Off (the default) -> `setup` returns nil and nothing is loaded or instrumented
11
11
  # (parity, zero overhead). The OTEL gems are only REQUIRED lazily in `setup`
@@ -17,7 +17,7 @@ module Insika
17
17
  # OTEL_* keys are the OpenTelemetry SDK's own env and stay verbatim; only our opt-in
18
18
  # toggle is renamed (INSIKA_OTEL, with the HARNESS_OTEL alias still read).
19
19
  #
20
- # Metrics (item 16 / P4) ride the same switch and the SAME standard env: the SDK
20
+ # Metrics ride the same switch and the SAME standard env: the SDK
21
21
  # registers a periodic metric reader when the OTLP metrics exporter is loadable,
22
22
  # and `OTEL_METRICS_EXPORTER=none` turns metrics off while traces stay on. No
23
23
  # Insika-specific toggle is invented for it.