insika 0.3.0 → 0.7.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 (190) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +180 -0
  3. data/README.md +45 -10
  4. data/bin/insika +684 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +94 -403
  7. data/docs/API.md +5 -5
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +14 -14
  12. data/docs/CONTEXT.md +9 -7
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +71 -3
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +128 -3
  17. data/docs/FACTS.md +3 -3
  18. data/docs/HARVEST.md +5 -6
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +2 -2
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +15 -10
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +14 -9
  27. data/docs/RELEASING.md +4 -4
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +3 -3
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +22 -6
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +2 -2
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +152 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +4 -4
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +8 -5
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +73 -16
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/channel_delivery.rb +1 -1
  62. data/lib/insika/chat_builder.rb +22 -2
  63. data/lib/insika/commands/agent_payload.rb +2 -2
  64. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  65. data/lib/insika/commands/delete_artifact.rb +35 -0
  66. data/lib/insika/commands/delete_concept.rb +34 -0
  67. data/lib/insika/commands/delete_mcp.rb +6 -2
  68. data/lib/insika/commands/delete_tenant_data.rb +15 -3
  69. data/lib/insika/commands/gate_refinement.rb +1 -1
  70. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  71. data/lib/insika/commands/restore_concept.rb +34 -0
  72. data/lib/insika/commands/seed_demo_data.rb +31 -0
  73. data/lib/insika/commands/upsert_mcp.rb +6 -3
  74. data/lib/insika/commands/write_concept.rb +57 -0
  75. data/lib/insika/context/priority.rb +2 -0
  76. data/lib/insika/context/providers/knowledge.rb +108 -0
  77. data/lib/insika/context/providers/prompt.rb +30 -24
  78. data/lib/insika/cron.rb +189 -0
  79. data/lib/insika/demo/agent_attrs.rb +43 -0
  80. data/lib/insika/demo/golden_cases.rb +81 -0
  81. data/lib/insika/demo/seeder.rb +336 -0
  82. data/lib/insika/doctor.rb +176 -8
  83. data/lib/insika/dsl/definition.rb +3 -2
  84. data/lib/insika/dsl/runtime.rb +60 -79
  85. data/lib/insika/dsl/server_boot.rb +23 -1
  86. data/lib/insika/dsl/system.rb +10 -2
  87. data/lib/insika/dsl.rb +103 -2
  88. data/lib/insika/env_schema.rb +16 -1
  89. data/lib/insika/evals/golden.rb +41 -4
  90. data/lib/insika/evals/judge.rb +47 -2
  91. data/lib/insika/evals/pairwise.rb +11 -0
  92. data/lib/insika/evals/persona.rb +98 -0
  93. data/lib/insika/evals/runner.rb +9 -0
  94. data/lib/insika/evals/simulator.rb +225 -0
  95. data/lib/insika/evals/transport.rb +83 -1
  96. data/lib/insika/event_stream.rb +10 -0
  97. data/lib/insika/executor.rb +231 -55
  98. data/lib/insika/followup_policy.rb +2 -25
  99. data/lib/insika/golden_store.rb +16 -1
  100. data/lib/insika/grounding/matcher.rb +1 -1
  101. data/lib/insika/knowledge.rb +680 -0
  102. data/lib/insika/knowledge_store.rb +140 -0
  103. data/lib/insika/mcp_client.rb +94 -0
  104. data/lib/insika/mcp_json.rb +74 -0
  105. data/lib/insika/mcp_live_tool.rb +43 -0
  106. data/lib/insika/mcp_store.rb +98 -26
  107. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  108. data/lib/insika/mcp_tool_registry.rb +100 -0
  109. data/lib/insika/media.rb +115 -31
  110. data/lib/insika/message_origin.rb +1 -1
  111. data/lib/insika/middleware.rb +9 -0
  112. data/lib/insika/onboarding.rb +17 -1
  113. data/lib/insika/outcome_store.rb +1 -1
  114. data/lib/insika/overlay_tool_registry.rb +37 -17
  115. data/lib/insika/packaging.rb +2 -2
  116. data/lib/insika/profile_source.rb +8 -1
  117. data/lib/insika/prompt_catalog.rb +10 -0
  118. data/lib/insika/retention.rb +36 -1
  119. data/lib/insika/router/app.rb +157 -0
  120. data/lib/insika/router/backend_pool.rb +98 -0
  121. data/lib/insika/router/hash_ring.rb +55 -0
  122. data/lib/insika/router/proxy_body.rb +34 -0
  123. data/lib/insika/router/session_key.rb +54 -0
  124. data/lib/insika/router.rb +18 -0
  125. data/lib/insika/schedule.rb +177 -0
  126. data/lib/insika/schedule_engine.rb +314 -0
  127. data/lib/insika/schedule_store.rb +208 -0
  128. data/lib/insika/server/app.rb +105 -15
  129. data/lib/insika/server/rack_app.rb +5 -1
  130. data/lib/insika/server/responses.rb +1 -1
  131. data/lib/insika/skill_catalog.rb +12 -0
  132. data/lib/insika/steer_injector.rb +21 -10
  133. data/lib/insika/studio/app.rb +567 -45
  134. data/lib/insika/studio/assets/dist/application.css +1 -1
  135. data/lib/insika/studio/assets/dist/application.js +21 -21
  136. data/lib/insika/studio/forms.rb +46 -5
  137. data/lib/insika/studio/nav_icons.rb +14 -1
  138. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  139. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  140. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  141. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  142. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  143. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  144. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  145. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  146. data/lib/insika/studio/views/_agents_master.erb +44 -0
  147. data/lib/insika/studio/views/_message.erb +49 -32
  148. data/lib/insika/studio/views/agent_detail.erb +61 -820
  149. data/lib/insika/studio/views/agents.erb +70 -57
  150. data/lib/insika/studio/views/artifact.erb +23 -0
  151. data/lib/insika/studio/views/artifacts.erb +59 -0
  152. data/lib/insika/studio/views/evals.erb +2 -2
  153. data/lib/insika/studio/views/facts.erb +1 -1
  154. data/lib/insika/studio/views/funnel.erb +1 -1
  155. data/lib/insika/studio/views/home.erb +106 -67
  156. data/lib/insika/studio/views/knowledge.erb +123 -0
  157. data/lib/insika/studio/views/layout.erb +14 -11
  158. data/lib/insika/studio/views/mcp.erb +174 -80
  159. data/lib/insika/studio/views/session.erb +231 -177
  160. data/lib/insika/studio/views/settings.erb +39 -1
  161. data/lib/insika/studio/views/skills.erb +1 -1
  162. data/lib/insika/studio/views/tools.erb +24 -9
  163. data/lib/insika/templates/browser-agent/README.md +36 -0
  164. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  165. data/lib/insika/templates/daily-digest/README.md +38 -0
  166. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  167. data/lib/insika/templates/repo-explorer/README.md +36 -0
  168. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  169. data/lib/insika/templates/research-analyst/README.md +26 -0
  170. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  171. data/lib/insika/templates/review-panel/README.md +20 -0
  172. data/lib/insika/templates/review-panel/agent.rb +50 -0
  173. data/lib/insika/templates/travel-planner/README.md +35 -0
  174. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  175. data/lib/insika/templates.rb +112 -0
  176. data/lib/insika/tick.rb +24 -12
  177. data/lib/insika/timezone.rb +45 -0
  178. data/lib/insika/tools/generate_image.rb +52 -7
  179. data/lib/insika/tools/load_knowledge.rb +74 -0
  180. data/lib/insika/tools/run_persona_eval.rb +328 -0
  181. data/lib/insika/tools/save_artifact.rb +95 -0
  182. data/lib/insika/turn_output.rb +1 -1
  183. data/lib/insika/turn_state.rb +15 -4
  184. data/lib/insika/version.rb +1 -1
  185. data/lib/insika/wiring/graph.rb +184 -12
  186. data/lib/insika/wiring/graph_chat.rb +102 -0
  187. data/lib/insika.rb +57 -0
  188. metadata +105 -5
  189. data/docs/build.md +0 -14
  190. data/docs/understand.md +0 -10
@@ -1,192 +1,246 @@
1
- <header class="page-head">
2
- <div>
3
- <nav class="crumbs"><a href="/studio/chats">chats</a> / <span>session</span></nav>
4
- <h1>Session
5
- <span class="copy-id" data-controller="clipboard" data-clipboard-text-value="<%= @session.id %>">
6
- <span class="mono"><%= @session.id[0, 16] %></span>
7
- <button type="button" class="copy-btn" data-clipboard-target="button" data-action="clipboard#copy" aria-label="Copy full session id">copy id</button>
8
- </span>
9
- <span class="sub"><%= Array(@session.messages).size %> message(s) · updated <%= @session.updated_at %></span>
10
- </h1>
11
- </div>
12
- <a class="btn" href="/studio/playground?session_id=<%= Rack::Utils.escape(@session.id) %>">Continue in playground →</a>
13
- </header>
14
-
15
- <% briefing = @session.respond_to?(:briefing) ? (@session.briefing || {}) : {} %>
16
- <% fields = briefing["fields"] || {} %>
17
- <% next_step = briefing["next_step"] %>
18
- <% if fields.any? || next_step %>
19
- <div class="card">
20
- <div class="trace-head">
21
- <h2>Briefing</h2>
22
- <span class="muted">this conversation's working state — read-only, written by the agent</span>
1
+ <%# Session viewer — three zones: conversation list |
2
+ transcript | metadata margin. The master column is the same
3
+ recent-conversations list the chats index renders; rows frame-navigate
4
+ (session-detail) with the URL advancing, so refresh/deep-link render the
5
+ same screen. The transcript itself is the product: role-labeled bubbles,
6
+ tool chips between them, thinking collapsed, session markers in the
7
+ margin (top-right ghost pills). %>
8
+ <%# Roda renders with plain locals — no local_assigns hash (Rails-ism). %>
9
+ <% frame_only = defined?(frame_only) && frame_only %>
10
+ <% unless frame_only %>
11
+ <header class="page-head">
12
+ <div>
13
+ <nav class="crumbs"><a href="/studio/chats">chats</a> / <span>session</span></nav>
14
+ <h1>Session
15
+ <span class="copy-id" data-controller="clipboard" data-clipboard-text-value="<%= @session.id %>">
16
+ <span class="mono"><%= @session.id[0, 16] %></span>
17
+ <button type="button" class="copy-btn" data-clipboard-target="button" data-action="clipboard#copy" aria-label="Copy full session id">copy id</button>
18
+ </span>
19
+ <span class="sub"><%= Array(@session.messages).size %> message(s) · updated <%= @session.updated_at %></span>
20
+ </h1>
23
21
  </div>
24
- <% fields.sort.each do |name, value| %>
25
- <div class="kv"><span><%= name %></span><div><%= value %></div></div>
26
- <% end %>
27
- <% if next_step %>
28
- <div class="kv"><span>next step</span><div><%= next_step %></div></div>
29
- <% end %>
30
- </div>
22
+ <a class="btn" href="/studio/playground?session_id=<%= Rack::Utils.escape(@session.id) %>">Continue in playground →</a>
23
+ </header>
24
+
25
+ <div class="drill" data-controller="list-filter">
26
+ <aside class="drill-pane drill-master">
27
+ <div class="drill-pane-head">
28
+ <div class="title"><strong>Conversations <span class="count"><%= @sessions.size %></span></strong></div>
29
+ </div>
30
+ <div class="drill-search">
31
+ <input type="search" class="filter-input" placeholder="Filter conversations…" aria-label="Filter conversations"
32
+ data-list-filter-target="query" data-action="input->list-filter#filter keydown->list-filter#clear">
33
+ </div>
34
+ <nav class="drill-pane-body drill-list" aria-label="conversations" data-controller="active-link">
35
+ <% @sessions.each do |s| %>
36
+ <a class="drill-item<%== ' active' if s.id == @session.id %>"
37
+ href="/studio/sessions/<%= Rack::Utils.escape(s.id) %>"
38
+ data-turbo-frame="session-detail" data-turbo-action="advance"
39
+ data-list-filter-target="item" data-filter-text="<%= s.id %> <%= session_preview(s) %>"
40
+ data-active-link-target="item" data-action="click->active-link#select">
41
+ <div class="drill-item-name"><span class="txt mono"><%= s.id[0, 18] %></span></div>
42
+ <div class="drill-item-sub"><%= Array(s.messages).size %> msg · <%= time_ago(s.updated_at) %></div>
43
+ </a>
44
+ <% end %>
45
+ </nav>
46
+ </aside>
31
47
  <% end %>
48
+ <turbo-frame id="session-detail" class="drill-pane drill-detail session-pane">
49
+ <div class="session-columns">
50
+ <%# ── Center: the transcript ───────────────────────────────────── %>
51
+ <section class="session-transcript" aria-label="transcript">
52
+ <%# Session markers in the margin: start, agent,
53
+ shape — ghost pills, right-aligned, from data the view already
54
+ has. %>
55
+ <% first_at = Array(@session.messages).first&.[]("at") %>
56
+ <% marker_agent = session_agent(@session) %>
57
+ <div class="marker-row">
58
+ <% unless short_time(first_at).empty? %><span class="ghost-pill">session · <%= short_time(first_at) %></span><% end %>
59
+ <% unless marker_agent.empty? %><span class="ghost-pill">agent · <%= marker_agent %></span><% end %>
60
+ <span class="ghost-pill"><%= Array(@session.messages).size %> messages</span>
61
+ </div>
32
62
 
33
- <% ctx_traces = @context_traces || [] %>
34
- <% unless ctx_traces.empty? %>
35
- <div class="card tooltrace">
36
- <div class="trace-head">
37
- <h2>Context</h2>
38
- <span class="muted">tokens by category per turn (estimated) no content stored</span>
39
- </div>
40
- <% ctx_traces.each do |c| %>
41
- <div class="trace-turn">
42
- <div class="turn-label">turn <%= c["turn"] %> · <%= c["at"] %></div>
43
- <%# Skills injected by context (triggers/skills_eager) leave NO tool call in
44
- the transcript, so this card is the only record that they were active.
45
- The names live in the body, but the count belongs in the SUMMARY: the
46
- card is collapsed, and "was the skill on?" is the question an operator
47
- asks on every single turn. %>
48
- <% skills = Array((c["categories"] || {}).dig("skilltrigger", "labels")).uniq %>
49
- <details class="trace-item ok">
50
- <summary>
51
- <span class="badge"><%= c["used"] %> / <%= c["cap"] %></span>
52
- <strong>context</strong>
53
- <%# the prefix cache-hit of the turn + the first block
54
- whose bytes changed vs the previous turn (PII-free category id). %>
55
- <% cache = c["cache"] %>
56
- <% if cache && cache["hit_pct"] %><span class="badge"><%= cache["hit_pct"] %>% cached</span><% end %>
57
- <% if cache && cache["invalidation_reason"] %>
58
- <span class="lat">broke: <%= cache["invalidation_reason"] %></span>
59
- <% end %>
60
- <% unless skills.empty? %>
61
- <span class="badge">skills <%= skills.length %></span>
62
- <% end %>
63
- <% unless Array(c["evicted"]).empty? %>
64
- <span class="lat">evicted: <%= Array(c["evicted"]).join(", ") %></span>
65
- <% end %>
66
- </summary>
67
- <% (c["categories"] || {}).sort_by { |_k, v| -(v["tokens"] || 0) }.each do |name, cat| %>
68
- <div class="kv"><span><%= name %></span>
69
- <div><%= cat["tokens"] %> tokens · <%= cat["fragments"] %> fragment(s)<%== " · pinned" if (cat["pinned"] || 0).positive? %><%== " · identity" if cat["layer"] == "identity" %>
70
- <%# WHICH skills the turn injected AND WHY — the only after-the-fact %>
71
- <%# record of activation once the bodies arrive by context instead of a %>
72
- <%# tool call. The reason is the half the operator was missing: a name %>
73
- <%# alone does not say whether they triggered it or the agent always has it. %>
74
- <% labels = Array(cat["labels"]).uniq %>
75
- <% unless labels.empty? %>
76
- <br><span class="lat"><%= labels.map { |l| skill_label(l) }.join(", ") %></span>
63
+ <% if Array(@session.messages).empty? %>
64
+ <div class="empty">This session has no messages.</div>
65
+ <% else %>
66
+ <div class="thread">
67
+ <%# A skill loaded by the model shows up here on its own: load_skill is a tool,
68
+ so its body is a persisted `role: tool` message. A skill injected by CONTEXT
69
+ (triggers/skills_eager) is part of the system prompt — never a message — so
70
+ the thread had no trace of it at all. Synthesized from the context trace so
71
+ both activation paths read the same way in the transcript.
72
+
73
+ Placed by TIME, not by counting turns: every message of a turn is persisted
74
+ together at the end of it, and the context stage always runs before that, so
75
+ each entry lands at the top of its own turn. Ordinal counting would drift on
76
+ a steered message (one that joins a turn already running). A message with no
77
+ `at` (older sessions) simply flushes nothing. %>
78
+ <% pending = (@context_traces || [])
79
+ .select { |c| Array((c["categories"] || {}).dig("skilltrigger", "labels")).any? }
80
+ .sort_by { |c| c["at"].to_s } %>
81
+ <% Array(@session.messages).each_with_index do |m, i| %>
82
+ <% while (entry = pending.first) && !m["at"].to_s.empty? && entry["at"].to_s <= m["at"].to_s %>
83
+ <% pending.shift %>
84
+ <% labels = Array(entry["categories"]["skilltrigger"]["labels"]).uniq %>
85
+ <details class="toolcard skill">
86
+ <summary><%== chip_icon(:skill) %><strong>skills · <%= skill_mode(labels) %> (<%= labels.length %>)</strong><span class="at"><%= entry["at"] %></span></summary>
87
+ <pre><%= labels.map { |l| skill_label(l) }.join("\n") %></pre>
88
+ </details>
77
89
  <% end %>
90
+ <%# `prev` is what makes a STEERED message visible: a customer message that lands
91
+ right after a tool result arrived mid-run. See _message.erb. %>
92
+ <%== render("_message", locals: { m: m, prev: i.positive? ? @session.messages[i - 1] : nil }) %>
93
+ <% end %>
94
+ </div>
95
+ <% end %>
96
+ </section>
97
+
98
+ <%# ── Right margin: the session's metadata ──────────────────────── %>
99
+ <aside class="session-margin" aria-label="session metadata">
100
+ <% briefing = @session.respond_to?(:briefing) ? (@session.briefing || {}) : {} %>
101
+ <% fields = briefing["fields"] || {} %>
102
+ <% next_step = briefing["next_step"] %>
103
+ <% if fields.any? || next_step %>
104
+ <div class="card">
105
+ <div class="trace-head">
106
+ <h2>Briefing</h2>
78
107
  </div>
108
+ <% fields.sort.each do |name, value| %>
109
+ <div class="kv"><span><%= name %></span><div><%= value %></div></div>
110
+ <% end %>
111
+ <% if next_step %>
112
+ <div class="kv"><span>next step</span><div><%= next_step %></div></div>
113
+ <% end %>
79
114
  </div>
80
115
  <% end %>
81
- <% tools = c["tools"] || {} %>
82
- <div class="kv"><span>tools</span>
83
- <div><%= tools["tokens"] %> tokens · <%= tools["count"] %> tool schema(s)</div>
84
- </div>
85
- </details>
86
- </div>
87
- <% end %>
88
- </div>
89
- <% end %>
90
116
 
91
- <% traces = @tool_traces || {} %>
92
- <% unless traces.empty? %>
93
- <div class="card tooltrace">
94
- <div class="trace-head">
95
- <h2>Tool calls</h2>
96
- <span class="muted">debug args + response, masked</span>
97
- </div>
98
- <% traces.keys.sort.each do |turn| %>
99
- <div class="trace-turn">
100
- <div class="turn-label">turn <%= turn %></div>
101
- <% traces[turn].each do |t| %>
102
- <details class="trace-item <%= t["ok"] ? "ok" : "err" %>">
103
- <summary>
104
- <span class="badge"><%= t["ok"] ? "ok" : "error" %></span>
105
- <strong><%= t["tool"] %></strong>
106
- <span class="lat"><%= t["ms"] %>ms</span>
107
- <span class="at"><%= t["at"] %></span>
108
- </summary>
109
- <div class="kv"><span>args</span>
110
- <div class="copywrap" data-controller="clipboard">
111
- <button type="button" class="copy-btn" data-clipboard-target="button" data-action="clipboard#copy">copy</button>
112
- <pre data-clipboard-target="source"><%= t["args"] %></pre>
117
+ <% ctx_traces = @context_traces || [] %>
118
+ <% unless ctx_traces.empty? %>
119
+ <div class="card tooltrace">
120
+ <div class="trace-head">
121
+ <h2>Context</h2>
122
+ <span class="muted">tokens by category per turn (estimated)</span>
113
123
  </div>
124
+ <% ctx_traces.each do |c| %>
125
+ <div class="trace-turn">
126
+ <div class="turn-label">turn <%= c["turn"] %> · <%= c["at"] %></div>
127
+ <%# Skills injected by context (triggers/skills_eager) leave NO tool call in
128
+ the transcript, so this card is the only record that they were active.
129
+ The names live in the body, but the count belongs in the SUMMARY: the
130
+ card is collapsed, and "was the skill on?" is the question an operator
131
+ asks on every single turn. %>
132
+ <% skills = Array((c["categories"] || {}).dig("skilltrigger", "labels")).uniq %>
133
+ <details class="trace-item ok">
134
+ <summary>
135
+ <span class="badge"><%= c["used"] %> / <%= c["cap"] %></span>
136
+ <strong>context</strong>
137
+ <%# the prefix cache-hit of the turn + the first block
138
+ whose bytes changed vs the previous turn (PII-free category id). %>
139
+ <% cache = c["cache"] %>
140
+ <% if cache && cache["hit_pct"] %><span class="badge"><%= cache["hit_pct"] %>% cached</span><% end %>
141
+ <% if cache && cache["invalidation_reason"] %>
142
+ <span class="lat">broke: <%= cache["invalidation_reason"] %></span>
143
+ <% end %>
144
+ <% unless skills.empty? %>
145
+ <span class="badge">skills <%= skills.length %></span>
146
+ <% end %>
147
+ <% unless Array(c["evicted"]).empty? %>
148
+ <span class="lat">evicted: <%= Array(c["evicted"]).join(", ") %></span>
149
+ <% end %>
150
+ </summary>
151
+ <% (c["categories"] || {}).sort_by { |_k, v| -(v["tokens"] || 0) }.each do |name, cat| %>
152
+ <div class="kv"><span><%= name %></span>
153
+ <div><%= cat["tokens"] %> tokens · <%= cat["fragments"] %> fragment(s)<%== " · pinned" if (cat["pinned"] || 0).positive? %><%== " · identity" if cat["layer"] == "identity" %>
154
+ <%# WHICH skills the turn injected AND WHY — the only after-the-fact %>
155
+ <%# record of activation once the bodies arrive by context instead of a %>
156
+ <%# tool call. The reason is the half the operator was missing: a name %>
157
+ <%# alone does not say whether they triggered it or the agent always has it. %>
158
+ <% labels = Array(cat["labels"]).uniq %>
159
+ <% unless labels.empty? %>
160
+ <br><span class="lat"><%= labels.map { |l| skill_label(l) }.join(", ") %></span>
161
+ <% end %>
162
+ </div>
163
+ </div>
164
+ <% end %>
165
+ <% tools = c["tools"] || {} %>
166
+ <div class="kv"><span>tools</span>
167
+ <div><%= tools["tokens"] %> tokens · <%= tools["count"] %> tool schema(s)</div>
168
+ </div>
169
+ </details>
170
+ </div>
171
+ <% end %>
114
172
  </div>
115
- <div class="kv"><span>response</span>
116
- <div class="copywrap" data-controller="clipboard">
117
- <button type="button" class="copy-btn" data-clipboard-target="button" data-action="clipboard#copy">copy</button>
118
- <pre data-clipboard-target="source"><%= t["result"] %></pre>
173
+ <% end %>
174
+
175
+ <% traces = @tool_traces || {} %>
176
+ <% unless traces.empty? %>
177
+ <div class="card tooltrace">
178
+ <div class="trace-head">
179
+ <h2>Tool calls</h2>
180
+ <span class="muted">debug — args + response, masked</span>
119
181
  </div>
182
+ <% traces.keys.sort.each do |turn| %>
183
+ <div class="trace-turn">
184
+ <div class="turn-label">turn <%= turn %></div>
185
+ <% traces[turn].each do |t| %>
186
+ <details class="trace-item <%= t["ok"] ? "ok" : "err" %>">
187
+ <summary>
188
+ <span class="badge"><%= t["ok"] ? "ok" : "error" %></span>
189
+ <strong><%= t["tool"] %></strong>
190
+ <span class="lat"><%= t["ms"] %>ms</span>
191
+ <span class="at"><%= t["at"] %></span>
192
+ </summary>
193
+ <div class="kv"><span>args</span>
194
+ <div class="copywrap" data-controller="clipboard">
195
+ <button type="button" class="copy-btn" data-clipboard-target="button" data-action="clipboard#copy">copy</button>
196
+ <pre data-clipboard-target="source"><%= t["args"] %></pre>
197
+ </div>
198
+ </div>
199
+ <div class="kv"><span>response</span>
200
+ <div class="copywrap" data-controller="clipboard">
201
+ <button type="button" class="copy-btn" data-clipboard-target="button" data-action="clipboard#copy">copy</button>
202
+ <pre data-clipboard-target="source"><%= t["result"] %></pre>
203
+ </div>
204
+ </div>
205
+ </details>
206
+ <% end %>
207
+ </div>
208
+ <% end %>
120
209
  </div>
121
- </details>
122
- <% end %>
123
- </div>
124
- <% end %>
125
- </div>
126
- <% end %>
210
+ <% end %>
127
211
 
128
- <%# the session's stage history — outcomes folded to the agent's
129
- declared stages (unmapped kinds render the raw outcome: the hole is visible).
130
- Without a declaration the block renders the raw outcomes. %>
131
- <% history = @stage_history || {} %>
132
- <% unless history.empty? %>
133
- <div class="card">
134
- <div class="trace-head">
135
- <h2>Stage history</h2>
136
- <span class="muted">
137
- <% if history[:declaration] %>
138
- <%= history[:declaration].stages.join(" → ") %> · outcome funnel
139
- <% else %>
140
- no funnel declared — raw outcomes
141
- <% end %>
142
- </span>
143
- </div>
144
- <div class="kv">
145
- <% history[:rows].each do |row| %>
146
- <% if row[:stage] %>
147
- <span><%= row[:outcome] %></span>
148
- <div>→ <strong><%= row[:stage] %></strong> <span class="lat"><%= row[:at] %></span></div>
149
- <% else %>
150
- <span><%= row[:outcome] %></span>
151
- <div><span class="lat">not a declared stage</span> <span class="lat"><%= row[:at] %></span></div>
152
- <% end %>
153
- <% end %>
154
- </div>
212
+ <%# the session's stage history — outcomes folded to the agent's
213
+ declared stages (unmapped kinds render the raw outcome: the hole is visible).
214
+ Without a declaration the block renders the raw outcomes. %>
215
+ <% history = @stage_history || {} %>
216
+ <% unless history.empty? %>
217
+ <div class="card">
218
+ <div class="trace-head">
219
+ <h2>Stage history</h2>
220
+ <span class="muted">
221
+ <% if history[:declaration] %>
222
+ <%= history[:declaration].stages.join(" → ") %> · outcome funnel
223
+ <% else %>
224
+ no funnel declared — raw outcomes
225
+ <% end %>
226
+ </span>
227
+ </div>
228
+ <div class="kv">
229
+ <% history[:rows].each do |row| %>
230
+ <% if row[:stage] %>
231
+ <span><%= row[:outcome] %></span>
232
+ <div>→ <strong><%= row[:stage] %></strong> <span class="lat"><%= row[:at] %></span></div>
233
+ <% else %>
234
+ <span><%= row[:outcome] %></span>
235
+ <div><span class="lat">not a declared stage</span> <span class="lat"><%= row[:at] %></span></div>
236
+ <% end %>
237
+ <% end %>
238
+ </div>
239
+ </div>
240
+ <% end %>
241
+ </aside>
242
+ </div>
243
+ </turbo-frame>
244
+ <% unless frame_only %>
155
245
  </div>
156
246
  <% end %>
157
-
158
- <div class="card transcript">
159
- <% if Array(@session.messages).empty? %>
160
- <div class="empty">This session has no messages.</div>
161
- <% else %>
162
- <div class="thread">
163
- <%# A skill loaded by the model shows up here on its own: load_skill is a tool,
164
- so its body is a persisted `role: tool` message. A skill injected by CONTEXT
165
- (triggers/skills_eager) is part of the system prompt — never a message — so
166
- the thread had no trace of it at all. Synthesized from the context trace so
167
- both activation paths read the same way in the transcript.
168
-
169
- Placed by TIME, not by counting turns: every message of a turn is persisted
170
- together at the end of it, and the context stage always runs before that, so
171
- each entry lands at the top of its own turn. Ordinal counting would drift on
172
- a steered message (one that joins a turn already running). A message with no
173
- `at` (older sessions) simply flushes nothing. %>
174
- <% pending = (@context_traces || [])
175
- .select { |c| Array((c["categories"] || {}).dig("skilltrigger", "labels")).any? }
176
- .sort_by { |c| c["at"].to_s } %>
177
- <% Array(@session.messages).each_with_index do |m, i| %>
178
- <% while (entry = pending.first) && !m["at"].to_s.empty? && entry["at"].to_s <= m["at"].to_s %>
179
- <% pending.shift %>
180
- <% labels = Array(entry["categories"]["skilltrigger"]["labels"]).uniq %>
181
- <details class="toolcard skill">
182
- <summary><span class="arrow">↑</span><strong>skills · <%= skill_mode(labels) %> (<%= labels.length %>)</strong><span class="at"><%= entry["at"] %></span></summary>
183
- <pre><%= labels.map { |l| skill_label(l) }.join("\n") %></pre>
184
- </details>
185
- <% end %>
186
- <%# `prev` is what makes a STEERED message visible: a customer message that lands
187
- right after a tool result arrived mid-run. See _message.erb. %>
188
- <%== render("_message", locals: { m: m, prev: i.positive? ? @session.messages[i - 1] : nil }) %>
189
- <% end %>
190
- </div>
191
- <% end %>
192
- </div>
@@ -5,7 +5,8 @@
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"],
8
- ["llm", "LLM providers", "keys, base URL, models per provider"]
8
+ ["llm", "LLM providers", "keys, base URL, models per provider"],
9
+ ["demo", "Demo data", "see every loop working at once"]
9
10
  ] %>
10
11
  <div class="drill">
11
12
  <aside class="drill-pane drill-master">
@@ -120,6 +121,43 @@
120
121
  </form>
121
122
  </div>
122
123
 
124
+ <% elsif @section == "demo" %>
125
+ <div class="drill-pane-head">
126
+ <div class="title"><strong>Demo data</strong></div>
127
+ </div>
128
+ <div class="drill-pane-body">
129
+ <p class="muted">
130
+ Populates the bundled <span class="mono">demo-store</span> agent with enough data
131
+ to see every loop working at once: a funnel with a frozen baseline, follow-ups in
132
+ all four states, refinement runs across the lifecycle, pending + resolved
133
+ approvals, distillation proposals/facts, and a golden set with a baseline.
134
+ The same seed the CLI's <span class="mono">insika demo:seed</span> runs.
135
+ </p>
136
+ <p class="muted">
137
+ This instance is currently <strong><%= @persistence.empty? ? "unknown" : @persistence %></strong>.
138
+ Seeding writes into <strong>this</strong> store — only fine to run against a scratch
139
+ or ephemeral instance, never against a store with real tenant data.
140
+ </p>
141
+ <form method="post" action="/studio/settings/seed-demo-data" class="actions"
142
+ data-turbo-confirm="Seed the demo-store agent into this <%= @persistence.empty? ? 'store' : @persistence %> instance?">
143
+ <%== csrf_tag %>
144
+ <label class="check">
145
+ <input type="checkbox" name="force" value="1">
146
+ reseed (append another batch) even if demo-store already exists
147
+ </label>
148
+ <button type="submit" class="primary" data-turbo-submits-with="Seeding…">Seed demo data</button>
149
+ </form>
150
+ <p class="muted">
151
+ Afterwards, open
152
+ <a href="/studio/funnel?agent=demo-store">Funnel</a>,
153
+ <a href="/studio/followups?agent=demo-store">Follow-ups</a>,
154
+ <a href="/studio/refinement?agent=demo-store">Refinement</a>,
155
+ <a href="/studio/approvals">Approvals</a>,
156
+ <a href="/studio/facts">Facts</a> and
157
+ <a href="/studio/evals?agent=demo-store">Evals</a>.
158
+ </p>
159
+ </div>
160
+
123
161
  <% else %>
124
162
  <div class="drill-pane-head">
125
163
  <div class="title"><strong>LLM providers <span class="count"><%= @providers.size %></span></strong></div>
@@ -19,7 +19,7 @@
19
19
  <a class="drill-item<%== ' active' if @selected == s.name %>"
20
20
  href="/studio/skills/<%= Rack::Utils.escape(s.name) %>"
21
21
  data-list-filter-target="item" data-filter-text="<%= s.name %> <%= s.description %>">
22
- <div class="drill-item-name"><span class="txt"><%= s.name %></span><% if @stored.include?(s.name) %><span class="pill info">authored</span><% end %></div>
22
+ <div class="drill-item-name"><span class="txt" title="<%= s.name %>"><%= s.name %></span><% if @stored.include?(s.name) %><span class="pill info">authored</span><% end %></div>
23
23
  <div class="drill-item-sub"><%= s.description %></div>
24
24
  </a>
25
25
  <% end %>
@@ -1,5 +1,10 @@
1
- <%# Tools — drill-down (T2): [data tools + agents] master | agent tool matrix.
2
- @sel_agent (from ?a=) drives the detail; data tools link to their editor page. %>
1
+ <%# Tools — miller columns: [data tools + agents] master |
2
+ agent tool matrix in a turbo-frame. Master rows for agents frame-navigate
3
+ (?a=) with the URL advancing; data-tool rows keep full-page navigation to
4
+ their standalone editor. @sel_agent (from ?a=) drives the detail. %>
5
+ <%# Roda renders with plain locals — no local_assigns hash (Rails-ism). %>
6
+ <% frame_only = defined?(frame_only) && frame_only %>
7
+ <% unless frame_only %>
3
8
  <div class="drill" data-controller="list-filter">
4
9
  <aside class="drill-pane drill-master">
5
10
  <div class="drill-pane-head">
@@ -10,14 +15,14 @@
10
15
  <input type="search" class="filter-input" placeholder="Filter agents…" aria-label="Filter agents"
11
16
  data-list-filter-target="query" data-action="input->list-filter#filter keydown->list-filter#clear">
12
17
  </div>
13
- <nav class="drill-pane-body drill-list" aria-label="tools and agents">
18
+ <nav class="drill-pane-body drill-list" aria-label="tools and agents" data-controller="active-link">
14
19
  <% unless @data_tool_names.empty? %>
15
20
  <div class="drill-group-label">Data tools · HTTP, editable</div>
16
21
  <% @data_tool_names.sort.each do |name| %>
17
22
  <% dropped = @dropped_tool_names.include?(name) %>
18
23
  <a class="drill-item drill-item-sm" href="/studio/tools/def/<%= Rack::Utils.escape(name) %>"
19
24
  <%== %( title="its definition is invalid — no agent can call it until it is fixed") if dropped %>>
20
- <div class="drill-item-name"><span class="txt"><%= name %></span><span class="pill plain">data</span><% if dropped %> <span class="pill locked">dropped</span><% end %></div>
25
+ <div class="drill-item-name"><span class="txt" title="<%= name %>"><%= name %></span><span class="pill plain">data</span><% if dropped %> <span class="pill locked">dropped</span><% end %></div>
21
26
  </a>
22
27
  <% end %>
23
28
  <% end %>
@@ -25,8 +30,10 @@
25
30
  <% @agents.each do |a| %>
26
31
  <a class="drill-item<%== ' active' if @sel_agent && @sel_agent.id == a.id %>"
27
32
  href="/studio/tools?a=<%= Rack::Utils.escape(a.id) %>"
28
- data-list-filter-target="item" data-filter-text="<%= a.id %>">
29
- <div class="drill-item-name"><span class="txt"><%= a.id %></span>
33
+ data-turbo-frame="tool-detail" data-turbo-action="advance"
34
+ data-list-filter-target="item" data-filter-text="<%= a.id %>"
35
+ data-active-link-target="item" data-action="click->active-link#select">
36
+ <div class="drill-item-name"><span class="txt" title="<%= a.id %>"><%= a.id %></span>
30
37
  <span class="pill plain"><%= a.tools_allow.nil? ? "all" : "#{tools_on_count(a, @tools)}/#{@tools.size}" %></span>
31
38
  </div>
32
39
  </a>
@@ -34,8 +41,12 @@
34
41
  <div class="empty" data-list-filter-target="empty" hidden>No agents match your filter.</div>
35
42
  </nav>
36
43
  </aside>
37
-
38
- <section class="drill-pane drill-detail">
44
+ <% end %>
45
+ <turbo-frame id="tool-detail" class="drill-pane drill-detail">
46
+ <% if frame_only %>
47
+ <% if flash["error"] %><div class="flash err" role="alert"><%= flash["error"] %></div><% end %>
48
+ <% if flash["notice"] %><div class="flash ok" role="status"><%= flash["notice"] %></div><% end %>
49
+ <% end %>
39
50
  <% if @sel_agent.nil? %>
40
51
  <div class="drill-empty">
41
52
  <div>
@@ -60,6 +71,8 @@
60
71
  <span class="switch-track" aria-hidden="true"></span>
61
72
  <span class="switch-label">all tools</span>
62
73
  </label>
74
+ <button type="button" class="btn ghost btn-sm" data-action="click->toggle-counter#selectNone"
75
+ title="uncheck every tool (deny-all — Save tools to apply)">none</button>
63
76
  </div>
64
77
  <p class="muted">Checked tools are allowed next turn. A <span class="pill locked">locked</span> tool is on the denylist — deny always wins, so it can't be granted here.</p>
65
78
  <form id="tools-form" method="post" action="/studio/tools/<%= Rack::Utils.escape(a.id) %>">
@@ -84,5 +97,7 @@
84
97
  </form>
85
98
  </div>
86
99
  <% end %>
87
- </section>
100
+ </turbo-frame>
101
+ <% unless frame_only %>
88
102
  </div>
103
+ <% end %>
@@ -0,0 +1,36 @@
1
+ # browser-agent
2
+
3
+ **MCP trail (stdio).** A live MCP tool-loop over stdio: the
4
+ engine spawns [Playwright's MCP server](https://github.com/microsoft/playwright-mcp)
5
+ (`npx @playwright/mcp@latest`) as a child process and wires its browser
6
+ tools straight into the agent's tool-loop. No API key beyond your LLM
7
+ provider's.
8
+
9
+ ## Before you run it
10
+
11
+ Two things this template needs beyond the gem and a provider key:
12
+
13
+ 1. **Node.js and npm** — `npx` spawns the MCP server as a child process.
14
+ 2. **`INSIKA_MCP_STDIO=1`** — a stdio MCP instance is arbitrary command
15
+ execution by config, so the engine refuses to start it until you opt in
16
+ explicitly (same discipline as the egress env vars for private hosts).
17
+
18
+ ```bash
19
+ node --version # confirm Node.js is installed
20
+ INSIKA_MCP_STDIO=1 DEEPSEEK_API_KEY=sk-... ruby browser-agent/agent.rb "go to example.com and summarize the page"
21
+ ```
22
+
23
+ Without `INSIKA_MCP_STDIO=1` the reply will say the tool call failed —
24
+ that's the gate working, not a bug.
25
+
26
+ ## Swap the server
27
+
28
+ This is not a Playwright showcase — it's exactly how you plug **any** MCP
29
+ server into an agent over stdio:
30
+
31
+ ```bash
32
+ MCP_COMMAND=your-mcp-server MCP_ARGS="--flag value" INSIKA_MCP_STDIO=1 DEEPSEEK_API_KEY=sk-... ruby browser-agent/agent.rb "..."
33
+ ```
34
+
35
+ Point `MCP_COMMAND`/`MCP_ARGS` at any stdio MCP server and rewrite the
36
+ instructions for its tools — nothing else in `agent.rb` changes.