insika 0.8.0 → 1.0.1

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 (276) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +312 -2
  3. data/README.md +10 -6
  4. data/bin/insika +4 -1
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +142 -18
  7. data/docs/API.md +73 -0
  8. data/docs/ARCHITECTURE.md +56 -50
  9. data/docs/ARTIFACTS.md +33 -3
  10. data/docs/BENCHMARK.md +50 -7
  11. data/docs/CHANNELS.md +19 -2
  12. data/docs/CONTEXT.md +96 -27
  13. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  14. data/docs/DEPLOY.md +70 -13
  15. data/docs/EMBEDDING.md +10 -0
  16. data/docs/EVALS.md +129 -10
  17. data/docs/FACTS.md +12 -6
  18. data/docs/HARVEST.md +8 -3
  19. data/docs/KNOWLEDGE.md +14 -0
  20. data/docs/MEDIA.md +6 -2
  21. data/docs/OBSERVABILITY.md +164 -28
  22. data/docs/PERFORMANCE.md +110 -0
  23. data/docs/PLUGINS.md +4 -4
  24. data/docs/POLICY.md +26 -14
  25. data/docs/RELEASING.md +9 -0
  26. data/docs/ROUTER.md +7 -0
  27. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  28. data/docs/SECURITY.md +34 -1
  29. data/docs/SHARED-CONVERSATIONS.md +40 -0
  30. data/docs/SKILLS.md +5 -3
  31. data/docs/STUDIO.md +122 -0
  32. data/docs/TOOLS.md +238 -42
  33. data/docs/operate.md +3 -0
  34. data/docs/prompts/ADD-TOOL.md +12 -2
  35. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  36. data/docs/prompts/GO-LIVE.md +3 -1
  37. data/lib/insika/agent_defaults.rb +140 -0
  38. data/lib/insika/agent_file_store.rb +12 -0
  39. data/lib/insika/agent_profile.rb +99 -17
  40. data/lib/insika/artifact_signing.rb +11 -0
  41. data/lib/insika/channels/web/widget.js +33 -0
  42. data/lib/insika/channels/web.rb +5 -2
  43. data/lib/insika/chat_builder.rb +124 -28
  44. data/lib/insika/checkpoint.rb +6 -1
  45. data/lib/insika/checkpoint_store.rb +59 -7
  46. data/lib/insika/commands/agent_payload.rb +3 -3
  47. data/lib/insika/commands/delete_kit.rb +26 -0
  48. data/lib/insika/commands/gate_harvest.rb +3 -0
  49. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  50. data/lib/insika/commands/run_distillation.rb +5 -8
  51. data/lib/insika/commands/run_harvest.rb +17 -1
  52. data/lib/insika/commands/seed_session.rb +128 -0
  53. data/lib/insika/commands/send_message.rb +9 -3
  54. data/lib/insika/commands/update_settings.rb +23 -1
  55. data/lib/insika/commands/write_kit.rb +27 -0
  56. data/lib/insika/compaction.rb +33 -7
  57. data/lib/insika/config_store.rb +87 -7
  58. data/lib/insika/context/builder.rb +29 -9
  59. data/lib/insika/context/priority.rb +5 -0
  60. data/lib/insika/context/provider.rb +7 -2
  61. data/lib/insika/context/providers/briefing.rb +11 -8
  62. data/lib/insika/context/providers/fence_notice.rb +27 -0
  63. data/lib/insika/context/providers/knowledge.rb +32 -6
  64. data/lib/insika/context/providers/memory.rb +61 -8
  65. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  66. data/lib/insika/context/providers/prompt.rb +17 -4
  67. data/lib/insika/context/providers/session.rb +46 -6
  68. data/lib/insika/context/reranker.rb +75 -0
  69. data/lib/insika/distill.rb +22 -13
  70. data/lib/insika/doctor.rb +142 -4
  71. data/lib/insika/dsl/runtime.rb +37 -4
  72. data/lib/insika/dsl/server_boot.rb +4 -12
  73. data/lib/insika/dsl.rb +19 -5
  74. data/lib/insika/edge_limiter.rb +27 -16
  75. data/lib/insika/egress_guard.rb +11 -3
  76. data/lib/insika/env_schema.rb +33 -73
  77. data/lib/insika/errors.rb +1 -0
  78. data/lib/insika/evals/assertions.rb +259 -18
  79. data/lib/insika/evals/golden.rb +230 -4
  80. data/lib/insika/evals/judge.rb +19 -10
  81. data/lib/insika/evals/moderator_compare.rb +47 -0
  82. data/lib/insika/evals/report.rb +61 -9
  83. data/lib/insika/evals/runner.rb +64 -4
  84. data/lib/insika/evals/simulator.rb +21 -3
  85. data/lib/insika/evals/transport.rb +317 -15
  86. data/lib/insika/event_stream.rb +63 -7
  87. data/lib/insika/evidence.rb +105 -17
  88. data/lib/insika/executor.rb +698 -164
  89. data/lib/insika/fence.rb +96 -0
  90. data/lib/insika/golden_store.rb +3 -0
  91. data/lib/insika/harvest.rb +26 -14
  92. data/lib/insika/harvest_engine.rb +1 -0
  93. data/lib/insika/harvest_store.rb +12 -0
  94. data/lib/insika/http_client.rb +87 -4
  95. data/lib/insika/kits.rb +54 -0
  96. data/lib/insika/knowledge.rb +34 -20
  97. data/lib/insika/llm_configurator.rb +46 -4
  98. data/lib/insika/llm_provider_store.rb +1 -3
  99. data/lib/insika/llm_trace_store.rb +54 -0
  100. data/lib/insika/mcp_client.rb +27 -63
  101. data/lib/insika/mcp_live_tool.rb +55 -9
  102. data/lib/insika/mcp_store.rb +37 -3
  103. data/lib/insika/mcp_tool_registry.rb +76 -26
  104. data/lib/insika/media.rb +38 -25
  105. data/lib/insika/memory_store.rb +16 -3
  106. data/lib/insika/memory_trim.rb +60 -0
  107. data/lib/insika/middleware.rb +12 -3
  108. data/lib/insika/model_metrics_store.rb +285 -0
  109. data/lib/insika/model_registry.rb +110 -0
  110. data/lib/insika/model_resolver.rb +1 -1
  111. data/lib/insika/model_selection.rb +14 -21
  112. data/lib/insika/model_visible.rb +17 -6
  113. data/lib/insika/onboarding.rb +1 -0
  114. data/lib/insika/outcome_store.rb +1 -1
  115. data/lib/insika/overlay_tool_registry.rb +30 -2
  116. data/lib/insika/pack_importer.rb +54 -9
  117. data/lib/insika/packaging.rb +14 -5
  118. data/lib/insika/pending_action_store.rb +45 -18
  119. data/lib/insika/plugin/loader.rb +4 -2
  120. data/lib/insika/prefix_fingerprint.rb +32 -27
  121. data/lib/insika/profile_source.rb +5 -0
  122. data/lib/insika/provider_error_classifier.rb +18 -3
  123. data/lib/insika/queue_policy.rb +7 -2
  124. data/lib/insika/refinement/proposer.rb +42 -17
  125. data/lib/insika/reliability.rb +12 -3
  126. data/lib/insika/router/app.rb +14 -5
  127. data/lib/insika/router/session_key.rb +35 -5
  128. data/lib/insika/safety/config.rb +20 -3
  129. data/lib/insika/safety/corpus.rb +10 -3
  130. data/lib/insika/safety/factory.rb +39 -1
  131. data/lib/insika/safety/input_guardrail.rb +21 -1
  132. data/lib/insika/safety/judge_moderator.rb +70 -0
  133. data/lib/insika/safety/output_validator.rb +29 -5
  134. data/lib/insika/safety/prompt_echo.rb +89 -0
  135. data/lib/insika/schema_guard.rb +53 -76
  136. data/lib/insika/server/app.rb +58 -7
  137. data/lib/insika/server/rack_app.rb +2 -0
  138. data/lib/insika/server/responses.rb +50 -5
  139. data/lib/insika/session_actor.rb +58 -28
  140. data/lib/insika/session_store.rb +163 -11
  141. data/lib/insika/settings_store.rb +57 -2
  142. data/lib/insika/shared_conversations.rb +305 -0
  143. data/lib/insika/skill_catalog.rb +28 -2
  144. data/lib/insika/skill_store.rb +6 -0
  145. data/lib/insika/soak/runner.rb +1 -1
  146. data/lib/insika/spoken_transcript.rb +31 -0
  147. data/lib/insika/steer_injector.rb +7 -48
  148. data/lib/insika/store.rb +29 -1
  149. data/lib/insika/stores/json_value.rb +37 -0
  150. data/lib/insika/stores/memory.rb +22 -35
  151. data/lib/insika/stores/postgres.rb +295 -0
  152. data/lib/insika/stores/sqlite.rb +257 -43
  153. data/lib/insika/stores/turn_counter.rb +25 -0
  154. data/lib/insika/studio/app.rb +1459 -163
  155. data/lib/insika/studio/assets/dist/application.css +1 -1
  156. data/lib/insika/studio/assets/dist/application.js +25 -25
  157. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  158. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  159. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  160. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  161. data/lib/insika/studio/forms.rb +189 -12
  162. data/lib/insika/studio/nav_icons.rb +3 -0
  163. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  164. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  165. data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
  166. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  167. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  168. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  169. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  170. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  171. data/lib/insika/studio/views/_agents_master.erb +8 -12
  172. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  173. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  174. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  175. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  176. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  177. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  178. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  179. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  180. data/lib/insika/studio/views/_chats_master.erb +66 -0
  181. data/lib/insika/studio/views/_customers_master.erb +37 -0
  182. data/lib/insika/studio/views/_empty.erb +15 -0
  183. data/lib/insika/studio/views/_facts_master.erb +42 -0
  184. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  185. data/lib/insika/studio/views/_identity.erb +16 -0
  186. data/lib/insika/studio/views/_kpi.erb +17 -0
  187. data/lib/insika/studio/views/_message.erb +40 -2
  188. data/lib/insika/studio/views/_model_chart.erb +82 -0
  189. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  190. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
  191. data/lib/insika/studio/views/_tool_users.erb +17 -0
  192. data/lib/insika/studio/views/_why.erb +53 -0
  193. data/lib/insika/studio/views/agent_detail.erb +17 -10
  194. data/lib/insika/studio/views/agents.erb +28 -16
  195. data/lib/insika/studio/views/approvals.erb +16 -11
  196. data/lib/insika/studio/views/artifact.erb +20 -7
  197. data/lib/insika/studio/views/artifacts.erb +19 -14
  198. data/lib/insika/studio/views/chats.erb +22 -29
  199. data/lib/insika/studio/views/customer.erb +88 -43
  200. data/lib/insika/studio/views/customers.erb +16 -26
  201. data/lib/insika/studio/views/evals.erb +49 -31
  202. data/lib/insika/studio/views/fact_detail.erb +114 -0
  203. data/lib/insika/studio/views/facts.erb +15 -116
  204. data/lib/insika/studio/views/followups.erb +63 -52
  205. data/lib/insika/studio/views/funnel.erb +104 -43
  206. data/lib/insika/studio/views/harvest.erb +85 -59
  207. data/lib/insika/studio/views/home.erb +138 -23
  208. data/lib/insika/studio/views/inbox.erb +36 -0
  209. data/lib/insika/studio/views/kits.erb +176 -0
  210. data/lib/insika/studio/views/knowledge.erb +92 -80
  211. data/lib/insika/studio/views/layout.erb +42 -11
  212. data/lib/insika/studio/views/live.erb +45 -0
  213. data/lib/insika/studio/views/mcp.erb +43 -13
  214. data/lib/insika/studio/views/models.erb +187 -0
  215. data/lib/insika/studio/views/parity.erb +38 -32
  216. data/lib/insika/studio/views/playground.erb +13 -9
  217. data/lib/insika/studio/views/refinement.erb +15 -224
  218. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  219. data/lib/insika/studio/views/session.erb +153 -118
  220. data/lib/insika/studio/views/settings.erb +86 -22
  221. data/lib/insika/studio/views/skills.erb +53 -19
  222. data/lib/insika/studio/views/system_files.erb +7 -7
  223. data/lib/insika/studio/views/task.erb +45 -15
  224. data/lib/insika/studio/views/tasks.erb +35 -18
  225. data/lib/insika/studio/views/tools.erb +62 -73
  226. data/lib/insika/task_actor.rb +4 -1
  227. data/lib/insika/task_store.rb +67 -1
  228. data/lib/insika/telemetry/pricing.rb +16 -14
  229. data/lib/insika/telemetry/recorder.rb +115 -9
  230. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  231. data/lib/insika/telemetry.rb +12 -2
  232. data/lib/insika/templates/daily-digest/README.md +13 -0
  233. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  234. data/lib/insika/testing/store_contract.rb +65 -0
  235. data/lib/insika/text_tool_call.rb +66 -0
  236. data/lib/insika/tick.rb +7 -1
  237. data/lib/insika/token_store.rb +5 -0
  238. data/lib/insika/tool_assembly.rb +21 -13
  239. data/lib/insika/tool_batch.rb +1 -1
  240. data/lib/insika/tool_catalog.rb +12 -2
  241. data/lib/insika/tool_definition.rb +82 -13
  242. data/lib/insika/tool_envelope.rb +266 -29
  243. data/lib/insika/tool_manifest.rb +1 -0
  244. data/lib/insika/tool_store.rb +12 -4
  245. data/lib/insika/tool_trace_store.rb +15 -8
  246. data/lib/insika/tool_usage_report.rb +14 -4
  247. data/lib/insika/tools/a2a_remote.rb +1 -1
  248. data/lib/insika/tools/agent_enum.rb +1 -1
  249. data/lib/insika/tools/confirm_pending.rb +112 -0
  250. data/lib/insika/tools/data_defined_tool.rb +24 -15
  251. data/lib/insika/tools/generate_image.rb +2 -2
  252. data/lib/insika/tools/load_knowledge.rb +7 -3
  253. data/lib/insika/tools/load_skill.rb +3 -3
  254. data/lib/insika/tools/present.rb +115 -0
  255. data/lib/insika/tools/remember.rb +7 -2
  256. data/lib/insika/tools/run_persona_eval.rb +9 -6
  257. data/lib/insika/tools/save_artifact.rb +16 -6
  258. data/lib/insika/tools/schedule_followup.rb +3 -3
  259. data/lib/insika/tools/stuck_signal.rb +3 -3
  260. data/lib/insika/tools/subagent.rb +5 -5
  261. data/lib/insika/tools/subagents.rb +3 -3
  262. data/lib/insika/tools/tool_search.rb +11 -7
  263. data/lib/insika/tools/tts.rb +2 -2
  264. data/lib/insika/tools/update_briefing.rb +4 -4
  265. data/lib/insika/turn_output.rb +2 -2
  266. data/lib/insika/turn_state.rb +31 -1
  267. data/lib/insika/turn_timing.rb +51 -14
  268. data/lib/insika/version.rb +1 -1
  269. data/lib/insika/wiring/graph.rb +88 -16
  270. data/lib/insika/wiring/graph_chat.rb +4 -0
  271. data/lib/insika/workflow.rb +25 -64
  272. data/lib/insika.rb +23 -3
  273. metadata +74 -13
  274. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  275. data/lib/insika/mcp_http_client.rb +0 -67
  276. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Platform defaults for agent fields (Settings["agent_defaults"]): an agent
5
+ # with no value of its own for an INHERITABLE field runs with the platform's.
6
+ # The agent's own value always wins, whole (no merge of the two).
7
+ #
8
+ # Applied only where turns read profiles (the graph, through ProfileSource
9
+ # below). Authoring (Studio, pack import) reads the agent's own record, so
10
+ # saving an agent never copies a default into it and it keeps inheriting.
11
+ #
12
+ # FIELDS are the ones where "absent" already means "not configured". Fields
13
+ # where absent means something else (tools_allow nil = every tool) or where
14
+ # absent and off read the same (the memory/fencing booleans) are left out.
15
+ module AgentDefaults
16
+ FIELDS = %w[reliability knowledge memory_retrieval budget alerts guardrails
17
+ grounding followup distill harvest].freeze
18
+
19
+ module_function
20
+
21
+ # -> the profile, rebuilt with the defaults it lacks (the same object when
22
+ # there is nothing to fill).
23
+ def apply(profile, defaults)
24
+ fill = FIELDS.each_with_object({}) do |field, acc|
25
+ value = defaults[field]
26
+ acc[field.to_sym] = value if !value.nil? && profile.public_send(field).nil?
27
+ end
28
+ return profile if fill.empty?
29
+
30
+ AgentProfile.build(**profile.to_h.merge(fill))
31
+ end
32
+
33
+ # Like #apply, but a default the agent cannot be built with (saved before it
34
+ # was validated, or a rerank model the registry dropped since) leaves the
35
+ # agent on its own values: one bad default must not stop every turn.
36
+ def apply_safely(profile, defaults)
37
+ apply(profile, defaults)
38
+ rescue Insika::ValidationError => e
39
+ warn "[insika] agent defaults not applied to #{profile.id}: #{e.message}"
40
+ profile
41
+ end
42
+
43
+ # Raises ValidationError unless an agent can be built with these defaults.
44
+ def validate!(defaults)
45
+ fields = defaults.to_h { |field, value| [field.to_sym, value] }
46
+ AgentProfile.build(id: "agent-defaults", model: nil, **fields)
47
+ defaults
48
+ end
49
+
50
+ # The source as turns read it (defaults + kits applied), for turn-time readers
51
+ # wired outside the graph (workflow triggers, persona evals). No settings store
52
+ # = nothing to apply, the source as is.
53
+ def for_turns(source, settings_store)
54
+ return source if settings_store.nil?
55
+
56
+ ProfileSource.new(Insika::ProfileSource.coerce(source), settings_store: settings_store)
57
+ end
58
+
59
+ # The agent's own records behind a profile source: what authoring reads and
60
+ # writes. A plain source is its own.
61
+ def own(source) = source.is_a?(ProfileSource) ? source.source : source
62
+
63
+ # A ProfileSource that hands out profiles with the platform defaults and the
64
+ # agent's kits (Insika::Kits) applied.
65
+ # Read-only: writing a profile read here would save the defaults into the
66
+ # agent, so put/delete refuse; writers use #source (AgentDefaults.own).
67
+ # Other reads (all_raw) go to the wrapped source untouched.
68
+ class ProfileSource
69
+ include Insika::ProfileSource
70
+
71
+ attr_reader :source
72
+
73
+ def initialize(source, settings_store:)
74
+ @source = source
75
+ @settings_store = settings_store
76
+ end
77
+
78
+ def fetch(id)
79
+ profile = @source.fetch(id)
80
+ profile && Insika::Kits.apply(fill(profile, normalized), kits)
81
+ end
82
+
83
+ def all
84
+ values = normalized
85
+ by_name = kits
86
+ @source.all.map { |profile| Insika::Kits.apply(fill(profile, values), by_name) }
87
+ end
88
+
89
+ def ids = @source.ids
90
+
91
+ def put(*) = raise(Insika::Error, "agent profiles are written through their own source (AgentDefaults.own)")
92
+ def delete(*) = put
93
+
94
+ def respond_to_missing?(name, include_private = false) = @source.respond_to?(name, include_private) || super
95
+
96
+ def method_missing(name, ...)
97
+ return super unless @source.respond_to?(name)
98
+
99
+ @source.public_send(name, ...)
100
+ end
101
+
102
+ private
103
+
104
+ # Settings["kits"] — applied after the defaults (Insika::Kits).
105
+ def kits = @settings_store.get["kits"] || {}
106
+
107
+ # Every inheritable field is normalized on its own by AgentProfile.build,
108
+ # so a profile takes the normalized values as they are (Data#with) and is
109
+ # not rebuilt per read.
110
+ def fill(profile, values)
111
+ missing = values.select { |field, _| profile.public_send(field).nil? }
112
+ missing.empty? ? profile : profile.with(**missing)
113
+ end
114
+
115
+ # The defaults as AgentProfile.build normalizes them, rebuilt only when the
116
+ # stored defaults change. Frozen: every profile shares them. A field that
117
+ # does not build is left out (warned once per change), so one bad default
118
+ # cannot stop every turn.
119
+ def normalized
120
+ raw = @settings_store.get["agent_defaults"] || {}
121
+ digest = raw.hash
122
+ return @normalized if digest == @normalized_digest
123
+
124
+ @normalized = build_values(raw.slice(*FIELDS).compact)
125
+ @normalized_digest = digest
126
+ @normalized
127
+ end
128
+
129
+ def build_values(raw)
130
+ raw.filter_map do |field, value|
131
+ built = AgentProfile.build(id: "agent-defaults", model: nil, field.to_sym => value)
132
+ [field.to_sym, Ractor.make_shareable(built.public_send(field))]
133
+ rescue Insika::ValidationError => e
134
+ warn "[insika] agent defaults: #{field} not applied: #{e.message}"
135
+ nil
136
+ end.to_h
137
+ end
138
+ end
139
+ end
140
+ end
@@ -31,6 +31,13 @@ module Insika
31
31
  entry(agent_id, filename.to_s)&.fetch("content", nil)
32
32
  end
33
33
 
34
+ # -> { name => content } every file of the agent, from ONE read of its record
35
+ # (which also carries the version history). Callers that need several files
36
+ # use this instead of `read` per file.
37
+ def contents(agent_id)
38
+ files(agent_id).transform_values { |entry| entry["content"] }
39
+ end
40
+
34
41
  # -> [String] the agent's file names, lexicographic order.
35
42
  def list(agent_id)
36
43
  files(agent_id).keys.sort
@@ -88,6 +95,11 @@ module Insika
88
95
  entry(agent_id, filename.to_s)&.fetch("history", []) || []
89
96
  end
90
97
 
98
+ # -> String | nil: when the current content was written (iso8601).
99
+ def updated_at(agent_id, filename)
100
+ entry(agent_id, filename.to_s)&.fetch("updated_at", nil)
101
+ end
102
+
91
103
  # Restores version `index` from history as the current content (a new write:
92
104
  # the current one goes to the top of history). -> Hash (entry) | raises if index
93
105
  # invalid / file nonexistent.
@@ -35,12 +35,17 @@ module Insika
35
35
  # a per-skill flag forced one decision onto every
36
36
  # allowlist holding the skill. NOT `Allowlist`
37
37
  # semantics — nil means NONE here (SkillCatalog#eager_for).
38
+ :kits, # kit names (Insika::Kits): bundles of skills/tools applied
39
+ # at READ time by AgentDefaults::ProfileSource — never
40
+ # copied into the record. Always a [String] ([] = none).
38
41
  :context_providers, # provider allowlist
39
42
  :workflows_allow, # applied by WorkflowAllowlist
40
43
  :policies, # names in the Policy Registry
41
44
  :prompt_refs, # names in the Prompt Catalog
42
45
  :limits, # timeouts/budgets
43
46
  :approvals_required, # tools that require approval (ApprovalRequired)
47
+ :customer_confirm, # tools the CUSTOMER confirms on the next message: the
48
+ # call is held, the turn ends with a question (nil = none)
44
49
  :capabilities, # intents the agent can trigger.
45
50
  # nil = NONE (opt-in, see above).
46
51
  :subagents, # allowlist of child agent ids this agent MAY spawn
@@ -51,19 +56,23 @@ module Insika
51
56
  :tools_deferred, # searchable-not-wired tools (Tool Search).
52
57
  # nil = no deferred (all eager — parity);
53
58
  # [names] ⊆ allowed_tools, exposed via tool_search.
59
+ :shared_conversations, # opt-in authoritative conversation persistence
54
60
  :memory, # cross-session memory.
55
61
  # nil/false = OFF (parity: provider []; the `remember`
56
62
  # tool not wired); true = ON. Same opt-in as capabilities.
63
+ :memory_retrieval, # optional reranked context selection; nil = legacy memory block.
57
64
  :prompt_caching, # Anthropic prompt caching (R3): nil/false = OFF
58
65
  # (parity); true = ON. Same opt-in as `memory`. When ON
59
66
  # AND the resolved provider is Anthropic, ChatBuilder sets
60
- # ONE cache breakpoint at the end of the system block
61
- # (caches tools+system by the tools->system->messages
62
- # prefix order; immune to history eviction). PRE-AUDIT:
63
- # the system prompt MUST be byte-stable between turns —
64
- # a context provider injecting volatile content into
65
- # :system turns every turn into a paid cache WRITE with
66
- # no read hit. Enable only for stable-system agents.
67
+ # the cache breakpoint at the end of the IDENTITY layer
68
+ # of the system (prompt, skills, tool index); memory,
69
+ # knowledge and briefing render below it as a second
70
+ # block, so a per-turn change there never re-writes the
71
+ # cached prefix. Immune to history eviction too
72
+ # (tools->system->messages prefix order). What still
73
+ # costs a WRITE every turn: a provider declaring
74
+ # `layer :identity` while emitting per-turn bytes —
75
+ # the doctor's cache-layers check flags it.
67
76
  :tool_persistence, # the engine's "Tool discipline" block in the system
68
77
  # prompt (retry weak/empty tool results with a different
69
78
  # approach before giving up). THE ONE OPT-OUT FIELD:
@@ -81,9 +90,22 @@ module Insika
81
90
  # THE MODEL SEES: an older full result is only the first
82
91
  # occurrence; a model that wants an older detail re-calls
83
92
  # the tool. Cheap half of compaction for bloated histories.
93
+ :fencing, # third-party text is sanitized before the model reads
94
+ # it: nil/false = OFF (parity — bytes reach the model
95
+ # as-is); true = ON. Same opt-in as `memory`. When ON,
96
+ # every tool result (after the evidence reshape), every
97
+ # <memory> fact/note and every <knowledge> concept pass
98
+ # Insika::Fence (NFKC, invisible/control characters out,
99
+ # transcript- and tool-call-shaped tags removed, forged
100
+ # turn markers defused, per-leaf cap), and the FenceNotice
101
+ # sentence rides under the identity. Default OFF this
102
+ # release: the goldens were baselined on unfenced bytes.
84
103
  :params, # LLM generation params: a Hash with
85
104
  # temperature/max_tokens/thinking, applied to the chat at
86
- # stage 5. {} = provider defaults (parity).
105
+ # stage 5. {} = provider defaults (parity). Plus
106
+ # `provider_routing` — which UPSTREAM serves the model, for
107
+ # a gateway that has several; emitted under the wire key
108
+ # `provider` (OpenRouter's shape).
87
109
  :budget, # spend caps per (tenant, agent) over
88
110
  # CALENDAR windows (WS2): { "daily" => int,
89
111
  # "monthly" => int, "soft" => bool, "alert_at" => 0.8 }.
@@ -318,11 +340,12 @@ module Insika
318
340
  # platform `default_model` (Settings) at turn start via the ModelResolver.
319
341
  def self.build(id:, model: nil, provider: nil, base_prompt: "", prompt_files: [],
320
342
  tools_allow: nil, tools_deny: [], tools_allow_groups: nil, skills: nil,
321
- skills_eager: nil, context_providers: nil, workflows_allow: nil,
343
+ skills_eager: nil, kits: nil, context_providers: nil, workflows_allow: nil,
322
344
  policies: [], prompt_refs: [], limits: {}, approvals_required: nil,
323
- capabilities: nil, subagents: nil, tools_deferred: nil, memory: nil,
345
+ customer_confirm: nil,
346
+ capabilities: nil, subagents: nil, tools_deferred: nil, memory: nil, memory_retrieval: nil, shared_conversations: false,
324
347
  prompt_caching: nil, tool_persistence: nil, tool_output_compression: nil,
325
- params: {}, model_policy: nil, guardrails: nil, sandbox: nil,
348
+ fencing: nil, params: {}, model_policy: nil, guardrails: nil, sandbox: nil,
326
349
  refinement: nil, capabilities_declared: nil, edge_stream: nil, metadata: {},
327
350
  budget: nil, reliability: nil, alerts: nil, routes: nil, stuck_signal: nil,
328
351
  outputs: nil, stt_prompt: nil, briefing_fields: nil, grounding: nil, funnel: nil,
@@ -332,18 +355,21 @@ outputs: nil, stt_prompt: nil, briefing_fields: nil, grounding: nil, funnel: nil
332
355
  prompt_files: Array(prompt_files), tools_allow: tools_allow,
333
356
  tools_deny: Array(tools_deny), tools_allow_groups: tools_allow_groups, skills: skills,
334
357
  skills_eager: skills_eager,
358
+ kits: Array(kits).map(&:to_s).uniq,
335
359
  context_providers: context_providers, workflows_allow: workflows_allow,
336
360
  policies: normalize_policies(policies, tools_allow: tools_allow, tools_deny: tools_deny,
337
361
  tools_allow_groups: tools_allow_groups),
338
362
  prompt_refs: Array(prompt_refs),
339
363
  limits: DEFAULT_LIMITS.merge(limits), approvals_required: approvals_required,
364
+ customer_confirm: normalize_customer_confirm(customer_confirm, approvals_required),
340
365
  capabilities: capabilities,
341
366
  # opt-in like capabilities: nil => NONE. Array-normalize a present value so
342
367
  # readers get a clean [] and the ChatBuilder gate (present? => wire) is stable.
343
368
  subagents: subagents.nil? ? nil : Array(subagents).map(&:to_s),
344
- tools_deferred: tools_deferred, memory: memory,
369
+ tools_deferred: tools_deferred, memory: memory, shared_conversations: shared_conversations == true,
370
+ memory_retrieval: normalize_memory_retrieval(memory_retrieval),
345
371
  prompt_caching: prompt_caching, tool_persistence: tool_persistence,
346
- tool_output_compression: tool_output_compression,
372
+ tool_output_compression: tool_output_compression, fencing: fencing,
347
373
  # The free-form hashes arrive with symbol keys (internal build) OR string
348
374
  # keys (StoredProfileSource JSON round-trip). Normalize to string keys ONCE
349
375
  # here — the single front door every profile passes through — so no reader
@@ -390,10 +416,9 @@ outputs: nil, stt_prompt: nil, briefing_fields: nil, grounding: nil, funnel: nil
390
416
  # free-form hashes; shape-validated by the command/engine/doctor
391
417
  # (never here — the refinement precedent). nil = off (parity).
392
418
  harvest: Coercion.deep_stringify(harvest),
393
- # knowledge is profile DATA, deep-stringified like the other
394
- # free-form hashes; shape-validated by the extractor/doctor
395
- # (never here — the refinement precedent). nil = off (parity).
396
- knowledge: Coercion.deep_stringify(knowledge),
419
+ # knowledge is profile DATA. Legacy extraction remains free-form;
420
+ # an explicit rerank declaration is validated when loaded.
421
+ knowledge: normalize_knowledge(knowledge),
397
422
  # schedules is profile DATA, deep-stringified like the other
398
423
  # free-form hashes (an ARRAY of declarations); parsed into
399
424
  # Insika::Schedule entries by the engine/doctor/Studio (shape-validated
@@ -402,6 +427,46 @@ outputs: nil, stt_prompt: nil, briefing_fields: nil, grounding: nil, funnel: nil
402
427
  )
403
428
  end
404
429
 
430
+ def self.normalize_knowledge(value)
431
+ config = Coercion.deep_stringify(value)
432
+ return config unless config.is_a?(Hash) && config.key?("rerank")
433
+
434
+ validate_rerank(config, "knowledge")
435
+ config
436
+ end
437
+
438
+ def self.normalize_memory_retrieval(value)
439
+ return nil if value.nil?
440
+
441
+ config = Coercion.deep_stringify(value)
442
+ raise ValidationError, "memory_retrieval must be an object" unless config.is_a?(Hash)
443
+
444
+ validate_rerank(config, "memory_retrieval", require_top_k: true)
445
+ config
446
+ end
447
+
448
+ def self.validate_rerank(config, field, require_top_k: false)
449
+ rerank = config["rerank"]
450
+ raise ValidationError, "#{field}.rerank must be an object" unless rerank.is_a?(Hash)
451
+
452
+ top_k = require_top_k ? config["top_k"] : config.fetch("top_k", 5)
453
+ limit = rerank["candidate_limit"]
454
+ timeout = rerank["timeout_seconds"]
455
+ valid = top_k.is_a?(Integer) && top_k.positive? &&
456
+ limit.is_a?(Integer) && limit >= top_k && limit <= 100 &&
457
+ timeout.is_a?(Numeric) && timeout.finite? && timeout.positive? &&
458
+ %w[provider model].all? { |key| rerank[key].is_a?(String) && !rerank[key].strip.empty? }
459
+ raise ValidationError, "invalid #{field}.rerank configuration" unless valid
460
+
461
+ require "ruby_llm"
462
+ begin
463
+ model = RubyLLM.models.find(rerank["model"], provider: rerank["provider"])
464
+ rescue RubyLLM::ModelNotFoundError
465
+ raise ValidationError, "#{field}.rerank model is not registered for provider"
466
+ end
467
+ raise ValidationError, "#{field}.rerank model does not support reranking" unless model.type == :rerank
468
+ end
469
+
405
470
  # Declaring a tool allow/deny list IS opting into it. The list is only ever
406
471
  # applied by the builtin `tool_allowlist` policy, and the Policy::Engine runs
407
472
  # ONLY the policies a profile names — so a profile with `tools_allow: [a, b]`
@@ -449,6 +514,23 @@ outputs: nil, stt_prompt: nil, briefing_fields: nil, grounding: nil, funnel: nil
449
514
  names
450
515
  end
451
516
 
517
+ # nil/empty -> nil (off); else [String], trimmed, uniq. A tool is confirmed by
518
+ # the customer OR approved by the operator, never both: the two hold a call in
519
+ # opposite ways (the turn ends with a question vs. the turn suspends), and one
520
+ # tool cannot do both at once.
521
+ def self.normalize_customer_confirm(list, approvals)
522
+ names = Array(list).map { |n| n.to_s.strip }.reject(&:empty?).uniq
523
+ return nil if names.empty?
524
+
525
+ both = names & Array(approvals).map(&:to_s)
526
+ unless both.empty?
527
+ raise Insika::ValidationError,
528
+ "customer_confirm and approvals_required overlap (#{both.join(', ')}): a tool is " \
529
+ "confirmed by the customer or approved by the operator, not both"
530
+ end
531
+ names
532
+ end
533
+
452
534
  # opt-in for an optional tool = being in the agent's allow list.
453
535
  def tool_opted_in?(name)
454
536
  Array(tools_allow).include?(name)
@@ -22,6 +22,17 @@ module Insika
22
22
  AUTHENTICATED_PATH = "/studio/artifacts/%{id}/content"
23
23
  SIGNED_PATH = "/studio/artifacts/s/%{id}?exp=%{exp}&sig=%{sig}"
24
24
 
25
+ # The Studio's signing config, read from the environment.
26
+ # -> { key:, ttl:, base_url: } | nil (no signing key -> no signed surface).
27
+ def studio_config
28
+ key = Insika::EnvSchema.read("INSIKA_ARTIFACT_SIGNING_KEY")
29
+ return nil if key.to_s.empty?
30
+
31
+ { key: key,
32
+ ttl: (Insika::EnvSchema.read("INSIKA_ARTIFACT_SIGNING_TTL") || 604_800).to_i,
33
+ base_url: Insika::Coercion.presence(Insika::EnvSchema.read("INSIKA_PUBLIC_URL")).to_s }
34
+ end
35
+
25
36
  # -> hex token (64 chars) | nil when the key is blank (no signed surface).
26
37
  def sign(id:, expires_at:, key:)
27
38
  key = key.to_s
@@ -158,6 +158,11 @@
158
158
  log.scrollTop = log.scrollHeight;
159
159
  } else if (event === "working" && !bubbleEl && !note) {
160
160
  note = say("note", "working…");
161
+ } else if (event === "ui" && data.items && data.items.length) {
162
+ // A presentation tool picked cards: a plain list of caption + link.
163
+ // The host page restyles or replaces this; the protocol is the frame.
164
+ if (note) { note.remove(); note = null; }
165
+ show(data);
161
166
  } else if (event === "error") {
162
167
  if (note) { note.remove(); note = null; }
163
168
  throw new Error(data.message || "something went wrong");
@@ -257,6 +262,34 @@
257
262
 
258
263
  // --- helpers --------------------------------------------------------
259
264
 
265
+ function show(ui) {
266
+ var list = el("ul", "insika-m ui");
267
+ if (ui.title) {
268
+ var head = el("li", "title");
269
+ head.textContent = ui.title;
270
+ list.appendChild(head);
271
+ }
272
+ ui.items.forEach(function (item) {
273
+ var li = el("li", "");
274
+ var label = item.caption || item.id || item.url;
275
+ // Only http(s) becomes a link: the url is backend data, and a
276
+ // `javascript:` or `data:` scheme must never be one click away.
277
+ if (/^https?:\/\//i.test(item.url || "")) {
278
+ var a = el("a", "");
279
+ a.href = item.url;
280
+ a.target = "_blank";
281
+ a.rel = "noopener";
282
+ a.textContent = label;
283
+ li.appendChild(a);
284
+ } else {
285
+ li.textContent = label;
286
+ }
287
+ list.appendChild(li);
288
+ });
289
+ log.appendChild(list);
290
+ log.scrollTop = log.scrollHeight;
291
+ }
292
+
260
293
  function say(kind, content) {
261
294
  var node = el("div", "insika-m " + kind);
262
295
  node.textContent = content;
@@ -155,8 +155,9 @@ module Insika
155
155
  # path — `POST /messages` with an unknown id is a 404, not a new conversation.
156
156
  def mint_session_id = "#{@id}:#{SecureRandom.hex(16)}"
157
157
 
158
- # Turn Event -> SSE frame | nil. Four frames, which is the whole widget
159
- # protocol: what to type, what to say while a tool runs, and how it ended.
158
+ # Turn Event -> SSE frame | nil. Five frames, which is the whole widget
159
+ # protocol: what to type, what to say while a tool runs, what to show
160
+ # (a presentation tool's cards), and how it ended.
160
161
  #
161
162
  # `:intermediate` and `:thinking` are deliberately absent. `:content` is the
162
163
  # ANSWER — the model's narration on the way there is internal, and a
@@ -165,6 +166,8 @@ module Insika
165
166
  case event.type
166
167
  when :content then sse("delta", { delta: event.data[:delta].to_s })
167
168
  when :tool_call then sse("working", { name: event.data[:name].to_s })
169
+ when :ui then sse("ui", { component: event.data[:component].to_s, title: event.data[:title],
170
+ items: Array(event.data[:items]) })
168
171
  when :task_completed then sse("done", {})
169
172
  when :task_failed then sse("error", { message: event.data[:message].to_s })
170
173
  when :task_cancelled then sse("error", { message: "task cancelled" })