insika 0.2.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 (300) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +325 -17
  3. data/README.md +52 -11
  4. data/bin/insika +895 -8
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +229 -163
  7. data/docs/API.md +56 -0
  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 +111 -21
  12. data/docs/CONTEXT.md +132 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +75 -4
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +146 -2
  17. data/docs/FACTS.md +135 -0
  18. data/docs/HARVEST.md +116 -0
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +16 -10
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +65 -6
  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 +17 -12
  27. data/docs/RELEASING.md +27 -0
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +6 -6
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +79 -2
  32. data/docs/SKILLS.md +13 -4
  33. data/docs/SOAK.md +127 -0
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +220 -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 +115 -0
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +9 -6
  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 +202 -10
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/balloon_splitter.rb +102 -0
  62. data/lib/insika/budget_ledger.rb +34 -3
  63. data/lib/insika/cache_series_store.rb +49 -0
  64. data/lib/insika/channel_delivery.rb +119 -14
  65. data/lib/insika/channels/relay.rb +77 -3
  66. data/lib/insika/channels/web.rb +2 -2
  67. data/lib/insika/chat_builder.rb +105 -2
  68. data/lib/insika/checkpoint_store.rb +16 -0
  69. data/lib/insika/commands/agent_payload.rb +3 -3
  70. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  71. data/lib/insika/commands/cancel_followup.rb +49 -0
  72. data/lib/insika/commands/delete_artifact.rb +35 -0
  73. data/lib/insika/commands/delete_concept.rb +34 -0
  74. data/lib/insika/commands/delete_mcp.rb +6 -2
  75. data/lib/insika/commands/delete_tenant_data.rb +107 -0
  76. data/lib/insika/commands/export_customer_memory.rb +48 -0
  77. data/lib/insika/commands/forget_customer.rb +117 -0
  78. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  79. data/lib/insika/commands/gate_harvest.rb +138 -0
  80. data/lib/insika/commands/gate_refinement.rb +1 -1
  81. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  82. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  83. data/lib/insika/commands/memory_put_fact.rb +23 -4
  84. data/lib/insika/commands/promote_harvest.rb +130 -0
  85. data/lib/insika/commands/record_outcome.rb +46 -0
  86. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  87. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  88. data/lib/insika/commands/reject_harvest.rb +38 -0
  89. data/lib/insika/commands/resolve_proposal.rb +108 -0
  90. data/lib/insika/commands/restore_concept.rb +34 -0
  91. data/lib/insika/commands/revoke_contact.rb +49 -0
  92. data/lib/insika/commands/rollback_harvest.rb +86 -0
  93. data/lib/insika/commands/run_distillation.rb +186 -0
  94. data/lib/insika/commands/run_harvest.rb +393 -0
  95. data/lib/insika/commands/seed_demo_data.rb +31 -0
  96. data/lib/insika/commands/send_message.rb +103 -6
  97. data/lib/insika/commands/session_purge.rb +67 -0
  98. data/lib/insika/commands/upsert_mcp.rb +6 -3
  99. data/lib/insika/commands/write_concept.rb +57 -0
  100. data/lib/insika/contact_store.rb +183 -0
  101. data/lib/insika/context/builder.rb +21 -3
  102. data/lib/insika/context/fragment.rb +7 -3
  103. data/lib/insika/context/priority.rb +5 -0
  104. data/lib/insika/context/provider.rb +17 -3
  105. data/lib/insika/context/providers/briefing.rb +96 -0
  106. data/lib/insika/context/providers/knowledge.rb +108 -0
  107. data/lib/insika/context/providers/memory.rb +16 -7
  108. data/lib/insika/context/providers/prompt.rb +57 -23
  109. data/lib/insika/context/providers/skill.rb +2 -0
  110. data/lib/insika/context/providers/tool_search.rb +2 -0
  111. data/lib/insika/context_trace_store.rb +38 -2
  112. data/lib/insika/cron.rb +189 -0
  113. data/lib/insika/demo/agent_attrs.rb +43 -0
  114. data/lib/insika/demo/golden_cases.rb +81 -0
  115. data/lib/insika/demo/seeder.rb +336 -0
  116. data/lib/insika/distill.rb +224 -0
  117. data/lib/insika/distill_engine.rb +169 -0
  118. data/lib/insika/doctor.rb +889 -11
  119. data/lib/insika/dsl/definition.rb +3 -2
  120. data/lib/insika/dsl/runtime.rb +69 -82
  121. data/lib/insika/dsl/server_boot.rb +92 -1
  122. data/lib/insika/dsl/system.rb +10 -2
  123. data/lib/insika/dsl.rb +186 -5
  124. data/lib/insika/edge_limiter.rb +28 -7
  125. data/lib/insika/env_schema.rb +25 -3
  126. data/lib/insika/errors.rb +11 -0
  127. data/lib/insika/evals/assertions.rb +3 -2
  128. data/lib/insika/evals/golden.rb +41 -4
  129. data/lib/insika/evals/judge.rb +47 -2
  130. data/lib/insika/evals/pairwise.rb +29 -6
  131. data/lib/insika/evals/persona.rb +98 -0
  132. data/lib/insika/evals/runner.rb +9 -0
  133. data/lib/insika/evals/simulator.rb +225 -0
  134. data/lib/insika/evals/transport.rb +83 -1
  135. data/lib/insika/event_stream.rb +10 -0
  136. data/lib/insika/evidence.rb +183 -0
  137. data/lib/insika/executor.rb +869 -76
  138. data/lib/insika/followup_engine.rb +207 -0
  139. data/lib/insika/followup_policy.rb +198 -0
  140. data/lib/insika/followup_store.rb +306 -0
  141. data/lib/insika/funnel_declaration.rb +106 -0
  142. data/lib/insika/funnel_fold.rb +179 -0
  143. data/lib/insika/funnel_store.rb +163 -0
  144. data/lib/insika/golden_store.rb +17 -2
  145. data/lib/insika/grounding/matcher.rb +69 -0
  146. data/lib/insika/grounding.rb +44 -0
  147. data/lib/insika/harvest/conversion_gate.rb +159 -0
  148. data/lib/insika/harvest/criterion.rb +98 -0
  149. data/lib/insika/harvest/gate.rb +194 -0
  150. data/lib/insika/harvest/negative_list.rb +199 -0
  151. data/lib/insika/harvest.rb +241 -0
  152. data/lib/insika/harvest_engine.rb +193 -0
  153. data/lib/insika/harvest_store.rb +548 -0
  154. data/lib/insika/knowledge.rb +680 -0
  155. data/lib/insika/knowledge_store.rb +140 -0
  156. data/lib/insika/mcp_client.rb +94 -0
  157. data/lib/insika/mcp_json.rb +74 -0
  158. data/lib/insika/mcp_live_tool.rb +43 -0
  159. data/lib/insika/mcp_store.rb +98 -26
  160. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  161. data/lib/insika/mcp_tool_registry.rb +100 -0
  162. data/lib/insika/media.rb +382 -0
  163. data/lib/insika/memory_audit_store.rb +85 -0
  164. data/lib/insika/memory_store.rb +264 -23
  165. data/lib/insika/message_origin.rb +7 -2
  166. data/lib/insika/middleware.rb +9 -0
  167. data/lib/insika/model_visible.rb +87 -0
  168. data/lib/insika/model_visible_trace_store.rb +66 -0
  169. data/lib/insika/onboarding.rb +22 -1
  170. data/lib/insika/outbox_store.rb +42 -4
  171. data/lib/insika/outcome_store.rb +147 -0
  172. data/lib/insika/overlay_tool_registry.rb +37 -17
  173. data/lib/insika/packaging.rb +163 -0
  174. data/lib/insika/parity/criterion.rb +79 -0
  175. data/lib/insika/parity/verdict.rb +318 -0
  176. data/lib/insika/prefix_fingerprint.rb +58 -0
  177. data/lib/insika/profile_source.rb +29 -1
  178. data/lib/insika/prompt_catalog.rb +10 -0
  179. data/lib/insika/proposal_store.rb +271 -0
  180. data/lib/insika/queue_policy.rb +4 -1
  181. data/lib/insika/refinement/proposer.rb +1 -1
  182. data/lib/insika/reliability.rb +32 -6
  183. data/lib/insika/retention.rb +316 -0
  184. data/lib/insika/router/app.rb +157 -0
  185. data/lib/insika/router/backend_pool.rb +98 -0
  186. data/lib/insika/router/hash_ring.rb +55 -0
  187. data/lib/insika/router/proxy_body.rb +34 -0
  188. data/lib/insika/router/session_key.rb +54 -0
  189. data/lib/insika/router.rb +18 -0
  190. data/lib/insika/routing.rb +101 -0
  191. data/lib/insika/safety/config.rb +44 -4
  192. data/lib/insika/safety/corpus.rb +255 -0
  193. data/lib/insika/safety/detectors.rb +32 -113
  194. data/lib/insika/safety/factory.rb +15 -2
  195. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  196. data/lib/insika/safety/grounding_validator.rb +49 -0
  197. data/lib/insika/safety/input_guardrail.rb +1 -1
  198. data/lib/insika/safety/moderator.rb +1 -1
  199. data/lib/insika/safety/output_filter.rb +9 -5
  200. data/lib/insika/safety/output_validator.rb +11 -5
  201. data/lib/insika/schedule.rb +177 -0
  202. data/lib/insika/schedule_engine.rb +314 -0
  203. data/lib/insika/schedule_store.rb +208 -0
  204. data/lib/insika/schema_guard.rb +35 -0
  205. data/lib/insika/server/app.rb +247 -17
  206. data/lib/insika/server/rack_app.rb +21 -1
  207. data/lib/insika/server/responses.rb +40 -1
  208. data/lib/insika/session_actor.rb +8 -4
  209. data/lib/insika/session_store.rb +65 -2
  210. data/lib/insika/settings_store.rb +10 -0
  211. data/lib/insika/shadow_pair_store.rb +258 -0
  212. data/lib/insika/skill_catalog.rb +16 -0
  213. data/lib/insika/soak/envelope.rb +140 -0
  214. data/lib/insika/soak/report.rb +392 -0
  215. data/lib/insika/soak/runner.rb +554 -0
  216. data/lib/insika/steer_injector.rb +21 -10
  217. data/lib/insika/store.rb +10 -1
  218. data/lib/insika/stores/memory.rb +6 -0
  219. data/lib/insika/stores/sqlite.rb +8 -0
  220. data/lib/insika/studio/app.rb +1437 -54
  221. data/lib/insika/studio/assets/dist/application.css +1 -1
  222. data/lib/insika/studio/assets/dist/application.js +23 -23
  223. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  224. data/lib/insika/studio/forms.rb +308 -10
  225. data/lib/insika/studio/nav_icons.rb +19 -1
  226. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  227. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  228. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  229. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  230. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  231. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  232. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  233. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  234. data/lib/insika/studio/views/_agents_master.erb +44 -0
  235. data/lib/insika/studio/views/_message.erb +49 -32
  236. data/lib/insika/studio/views/agent_detail.erb +62 -278
  237. data/lib/insika/studio/views/agents.erb +71 -54
  238. data/lib/insika/studio/views/approvals.erb +4 -1
  239. data/lib/insika/studio/views/artifact.erb +23 -0
  240. data/lib/insika/studio/views/artifacts.erb +59 -0
  241. data/lib/insika/studio/views/chats.erb +4 -1
  242. data/lib/insika/studio/views/customer.erb +94 -0
  243. data/lib/insika/studio/views/customers.erb +32 -0
  244. data/lib/insika/studio/views/evals.erb +6 -3
  245. data/lib/insika/studio/views/facts.erb +133 -0
  246. data/lib/insika/studio/views/followups.erb +125 -0
  247. data/lib/insika/studio/views/funnel.erb +106 -0
  248. data/lib/insika/studio/views/harvest.erb +234 -0
  249. data/lib/insika/studio/views/home.erb +106 -66
  250. data/lib/insika/studio/views/knowledge.erb +123 -0
  251. data/lib/insika/studio/views/layout.erb +15 -11
  252. data/lib/insika/studio/views/mcp.erb +174 -80
  253. data/lib/insika/studio/views/parity.erb +147 -0
  254. data/lib/insika/studio/views/playground.erb +7 -1
  255. data/lib/insika/studio/views/session.erb +233 -124
  256. data/lib/insika/studio/views/settings.erb +41 -1
  257. data/lib/insika/studio/views/skills.erb +1 -2
  258. data/lib/insika/studio/views/system_files.erb +1 -1
  259. data/lib/insika/studio/views/task.erb +13 -0
  260. data/lib/insika/studio/views/tasks.erb +4 -1
  261. data/lib/insika/studio/views/tools.erb +24 -10
  262. data/lib/insika/task_store.rb +21 -1
  263. data/lib/insika/templates/browser-agent/README.md +36 -0
  264. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  265. data/lib/insika/templates/daily-digest/README.md +38 -0
  266. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  267. data/lib/insika/templates/repo-explorer/README.md +36 -0
  268. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  269. data/lib/insika/templates/research-analyst/README.md +26 -0
  270. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  271. data/lib/insika/templates/review-panel/README.md +20 -0
  272. data/lib/insika/templates/review-panel/agent.rb +50 -0
  273. data/lib/insika/templates/travel-planner/README.md +35 -0
  274. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  275. data/lib/insika/templates.rb +112 -0
  276. data/lib/insika/testing/store_contract.rb +27 -6
  277. data/lib/insika/tick.rb +47 -11
  278. data/lib/insika/timezone.rb +45 -0
  279. data/lib/insika/tool_definition.rb +17 -7
  280. data/lib/insika/tool_envelope.rb +69 -0
  281. data/lib/insika/tool_manifest.rb +5 -1
  282. data/lib/insika/tools/data_defined_tool.rb +10 -0
  283. data/lib/insika/tools/generate_image.rb +89 -0
  284. data/lib/insika/tools/load_knowledge.rb +74 -0
  285. data/lib/insika/tools/run_persona_eval.rb +328 -0
  286. data/lib/insika/tools/save_artifact.rb +95 -0
  287. data/lib/insika/tools/schedule_followup.rb +164 -0
  288. data/lib/insika/tools/tts.rb +47 -0
  289. data/lib/insika/tools/update_briefing.rb +126 -0
  290. data/lib/insika/turn_output.rb +1 -1
  291. data/lib/insika/turn_state.rb +49 -1
  292. data/lib/insika/turn_timing.rb +22 -2
  293. data/lib/insika/version.rb +1 -1
  294. data/lib/insika/vitals.rb +84 -0
  295. data/lib/insika/wiring/graph.rb +472 -13
  296. data/lib/insika/wiring/graph_chat.rb +102 -0
  297. data/lib/insika.rb +149 -3
  298. metadata +181 -5
  299. data/docs/build.md +0 -14
  300. data/docs/understand.md +0 -10
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # LIVE MCP tools — kills the McpToolIngestor snapshot.
5
+ #
6
+ # `entries` is CHEAP: it reads only McpStore#tools_cache (no I/O), the same
7
+ # contract the ToolStore-backed dynamic entries already give
8
+ # OverlayToolRegistry — Policy recomputes `candidate_tools` every turn, so
9
+ # an unreachable MCP server must never add real latency there.
10
+ #
11
+ # `refresh` is the ONLY thing that talks to a server ahead of time: it
12
+ # connects, lists live, and writes the result back to tools_cache (display/
13
+ # doctor). Calling a tool never depends on that cache — Insika::McpLiveTool
14
+ # always goes through the live, memoized client (and RubyLLM::MCP::Client
15
+ # does its own real `tools/list` on ITS first use per instance, regardless
16
+ # of whether `refresh` ever ran).
17
+ class McpToolRegistry
18
+ def initialize(mcp_store:, client_factory: Insika::McpClient.method(:for))
19
+ @mcp_store = mcp_store
20
+ @client_factory = client_factory
21
+ @clients = {}
22
+ @mutex = Mutex.new
23
+ end
24
+
25
+ # -> [Registry::Entry] one per cached tool of every ENABLED instance.
26
+ def entries
27
+ @mcp_store.all_raw.select { |r| r["enabled"] }.flat_map { |r| entries_for(r) }
28
+ end
29
+
30
+ # Connects to `name` LIVE, lists its tools, and writes McpStore#tools_cache.
31
+ # ALWAYS drops any memoized client first and rebuilds from the current
32
+ # record: a stdio process can be `alive?` (still running) yet permanently
33
+ # broken (e.g. wrong transport args — see grafana-stg gotcha), and an
34
+ # edited command/url/env must take effect without a process restart —
35
+ # there's no SSH into a Railway dyno to do that by hand.
36
+ # -> the discovered [{"name","description","inputSchema"}]. Raises on a
37
+ # missing/disabled instance or a transport failure — the caller (a CLI
38
+ # verb/API route/UI button, PR3/PR4) decides how to surface it.
39
+ def refresh(name)
40
+ record = @mcp_store.get_raw(name.to_s)
41
+ raise Insika::NotFoundError, "MCP instance '#{name}' not found" if record.nil?
42
+ raise Insika::ValidationError, "MCP instance '#{name}' is disabled" unless record["enabled"]
43
+
44
+ evict(record["name"])
45
+ client = client_for(record)
46
+ discovered = client.tools.map { |t| { "name" => t.name, "description" => t.description, "inputSchema" => t.params_schema } }
47
+ @mcp_store.set_tools_cache(name, discovered)
48
+ discovered
49
+ end
50
+
51
+ # Stops and drops any memoized client for `name` (no-op if none), so the
52
+ # next `client_for` builds a fresh one off the current record. Public:
53
+ # called by `refresh` above AND by UpsertMcp/DeleteMcp right after they
54
+ # write the store — a Studio/CLI edit takes effect on that instance's
55
+ # NEXT tool call or refresh, no process restart required (there's no way
56
+ # to restart a process by hand on a Railway dyno).
57
+ def evict(name)
58
+ client = @mutex.synchronize { @clients.delete(name.to_s) }
59
+ client&.stop
60
+ rescue StandardError
61
+ nil # best-effort teardown of a possibly already-dead process
62
+ end
63
+
64
+ private
65
+
66
+ def entries_for(record)
67
+ Array(record["tools_cache"]).map { |tool| entry_for(record, tool) }
68
+ end
69
+
70
+ def entry_for(record, tool)
71
+ instance = record["name"]
72
+ Insika::Registry::Entry.new(
73
+ name: tool["name"], plugin: "mcp:#{instance}",
74
+ metadata: { optional: false, side_effect: true, group: "mcp:#{instance}", tags: [] },
75
+ factory: -> { build_tool(record, tool) }
76
+ )
77
+ end
78
+
79
+ # Lazy require (McpLiveTool < RubyLLM::Tool pulls in ruby_llm) — kept out
80
+ # of insika.rb load-time, loaded on the 1st instance (turn time), same
81
+ # discipline as OverlayToolRegistry#build_tool for data-tools.
82
+ def build_tool(record, tool)
83
+ require_relative "mcp_live_tool"
84
+ Insika::McpLiveTool.new(instance_name: record["name"], tool: tool, client_for: -> { client_for(record) })
85
+ end
86
+
87
+ # A started, MEMOIZED client for `record` — one real connection per
88
+ # instance name, reused across calls/turns. Raises on a gated/unreachable
89
+ # instance; only called from `refresh` and from a running McpLiveTool's
90
+ # `#execute` (which rescues) — never from `entries`/`build_tool`, so a
91
+ # downed server never breaks turn ASSEMBLY, only that tool's own call.
92
+ def client_for(record)
93
+ @mutex.synchronize do
94
+ client = (@clients[record["name"]] ||= @client_factory.call(record))
95
+ client.start unless client.alive?
96
+ client
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # WS9: the engine transports MEDIA, never meaning. Content parts ride the
5
+ # message contract — `{ "type": "text", "text": … }`, `{ "type": "image",
6
+ # "url": … }`, `{ "type": "audio", "url": … }`, `{ "type": "document",
7
+ # "url": … }` — and the Executor turns them into a turn: audio is
8
+ # transcribed (text marked `source: :voice`), images and documents attach
9
+ # to the model ask and the first URL of each kind is `{{ctx.image_url}}` /
10
+ # `{{ctx.document_url}}` for data tools. This class owns the PURE parts
11
+ # (normalization) and the STT SEAM (injectable — specs stub it; the default
12
+ # fetches the audio and transcribes via RubyLLM behind a lazy require, so
13
+ # the core stays gem-free at load). `Media::Output` is the generated-media
14
+ # half (WS9, saída): the turn can PRODUCE an image or an audio clip when the
15
+ # agent opted in (`AgentProfile#outputs`) AND the channel declared it can
16
+ # receive it (`channel.capabilities`) — nothing leaks by default.
17
+ module Media
18
+ # A single content part, normalized.
19
+ Part = Data.define(:type, :text, :url) do
20
+ def audio? = type == "audio"
21
+ def image? = type == "image"
22
+ def document? = type == "document"
23
+ def text? = type == "text"
24
+ end
25
+
26
+ # -> [Part]: normalize the raw parts (string|symbol keys), skipping anything
27
+ # that is not a well-formed text/image/audio/document part. Lenient on
28
+ # purpose — the SURFACE validates the contract with `well_formed?` (a
29
+ # malformed part is a 422 before dispatch); here a stray entry must not
30
+ # break the turn.
31
+ def self.parts(raw)
32
+ Array(raw).filter_map do |p|
33
+ next unless p.is_a?(Hash)
34
+
35
+ type = (p[:type] || p["type"]).to_s
36
+ url = (p[:url] || p["url"]).to_s
37
+ text = (p[:text] || p["text"]).to_s
38
+ case type
39
+ when "text" then text.empty? ? nil : Part.new("text", text, nil)
40
+ when "image", "audio", "document" then url.empty? ? nil : Part.new(type, nil, url)
41
+ else nil
42
+ end
43
+ end
44
+ end
45
+
46
+ # The SURFACE's contract check (server edge): true when EVERY entry is a
47
+ # well-formed content part — a Hash whose type is text (with text), image,
48
+ # audio or document (with url). The edge raises a 422 on the first
49
+ # offender; the engine itself stays lenient (`parts` skips strays so a
50
+ # non-HTTP transport that bypassed the edge cannot break a turn).
51
+ def self.well_formed?(raw)
52
+ Array(raw).all? do |p|
53
+ next false unless p.is_a?(Hash)
54
+
55
+ case (p[:type] || p["type"]).to_s
56
+ when "text" then !(p[:text] || p["text"]).to_s.empty?
57
+ when "image", "audio", "document" then !(p[:url] || p["url"]).to_s.empty?
58
+ # a part WITHOUT a type is admitted only as a bare text part (the
59
+ # shape the input joiner already tolerates) — anything else is refused.
60
+ when "" then !(p[:text] || p["text"]).to_s.empty?
61
+ else false
62
+ end
63
+ end
64
+ end
65
+
66
+ # The OUTPUT media kinds a channel may declare it can receive
67
+ # (`channel.capabilities`). The closed list is the "abstraction admits
68
+ # only what leaks" rule: an unknown value is refused at the edge, never
69
+ # silently ignored.
70
+ OUTPUT_CAPABILITIES = %w[image_output audio_output].freeze
71
+
72
+ # -> [String]: the capabilities a raw `channel` hash declares. Lenient on
73
+ # the key spelling (symbol|string) at both boundaries (request parse vs
74
+ # persisted command payload); [] = the channel declared nothing.
75
+ def self.channel_capabilities(raw)
76
+ channel = raw.is_a?(Hash) ? raw : {}
77
+ Array(channel[:capabilities] || channel["capabilities"]).map(&:to_s)
78
+ end
79
+
80
+ # The ceilings on INBOUND media (a URL a consumer sent us). Both fetches
81
+ # stream into the cap and refuse past it: the bytes land in THIS process,
82
+ # so an uncapped one is a hostile URL away from growing it until it dies.
83
+ MAX_AUDIO_BYTES = 1_000_000 # a voice note, not a warehouse
84
+ MAX_IMAGE_BYTES = 5_000_000 # a photo, not a poster
85
+ MAX_DOCUMENT_BYTES = 10_000_000 # a prescription, not an archive
86
+
87
+ def self.audio_parts(parts) = parts.select(&:audio?)
88
+ def self.image_parts(parts) = parts.select(&:image?)
89
+ def self.document_parts(parts) = parts.select(&:document?)
90
+
91
+ # The STT seam: ->(url) { text } (default: fetch + RubyLLM transcription).
92
+ # Injected so a spec never touches the network; the default is built lazily
93
+ # when the turn first carries audio. `stt_prompt` is the Whisper-family
94
+ # vocabulary hint (product names, brand terms) — OPERATOR config
95
+ # (agent profile / deployment env), never customer input.
96
+ def self.default_transcriber(stt_model:, stt_language: nil, stt_prompt: nil)
97
+ lambda do |url|
98
+ fetch_and_transcribe(url, model: stt_model, language: stt_language, prompt: stt_prompt)
99
+ end
100
+ end
101
+
102
+ # A file PATH, not bytes and not an Attachment: `RubyLLM::Transcription.
103
+ # transcribe` hands its argument to the PROVIDER's own `transcribe`, and the
104
+ # two shapes in this gem disagree — Gemini wraps it in `Attachment.new`
105
+ # itself (a raw byte String there is misread as a Pathname and blows up on
106
+ # any embedded null byte, which real audio has), while the DEFAULT
107
+ # `Provider#transcribe` (OpenAI, Mistral) calls `File.expand_path` on it
108
+ # directly and cannot take bytes/IO/Attachment at all. A tempfile is the
109
+ # one shape both accept. `assume_model_exists` is deliberately NOT passed:
110
+ # RubyLLM raises ArgumentError when it's true without an explicit
111
+ # `provider` (see ModelSelection#assume_model_exists?), and `stt_model` here
112
+ # is a bare ref like `utility_model` elsewhere — the registry resolves it.
113
+ def self.fetch_and_transcribe(url, model:, language:, prompt: nil)
114
+ require "net/http"
115
+ require "uri"
116
+ require "ruby_llm" # lazy — the core loads without it (load-guard)
117
+ require "tempfile"
118
+
119
+ bytes = fetch_binary(url)
120
+ options = { model: model }
121
+ options[:language] = language if language
122
+ options[:prompt] = prompt if prompt
123
+ Tempfile.create(["insika-media-", File.extname(filename_for(url).to_s)]) do |file|
124
+ file.binmode
125
+ file.write(bytes)
126
+ file.flush
127
+ RubyLLM::Transcription.transcribe(file.path, **options).text
128
+ end
129
+ end
130
+
131
+ # An inbound URL -> a RubyLLM::Attachment over bytes WE fetched (egress-
132
+ # guarded, size-capped — the `media_attachment` recipe). Shared by the
133
+ # Executor (inbound image/document parts) and `Output.generate_image`
134
+ # (edit sources / mask): an io-like source (StringIO) is the branch of
135
+ # Attachment that takes bytes already held, so the provider gets base64
136
+ # rather than the URL — handing the raw URL to RubyLLM instead would leave
137
+ # the (uncapped) fetch to the gem.
138
+ def self.url_attachment(url, max_bytes: MAX_IMAGE_BYTES)
139
+ require "ruby_llm"
140
+ require "stringio"
141
+
142
+ bytes = fetch_binary(url, max_bytes: max_bytes)
143
+ RubyLLM::Attachment.new(StringIO.new(bytes), filename: filename_for(url))
144
+ end
145
+
146
+ # The URL's basename, for the attachment's mime sniff (".png" -> image/png;
147
+ # a URL with no filename falls back to the content sniff RubyLLM does).
148
+ def self.filename_for(url)
149
+ require "uri"
150
+ name = File.basename(URI.parse(url).path.to_s)
151
+ name.empty? ? nil : name
152
+ rescue URI::InvalidURIError
153
+ nil
154
+ end
155
+
156
+ # Egress-guarded binary fetch of a media URL. Blocked like the webhook: the
157
+ # url is consumer config/input, so a private/loopback/metadata target is
158
+ # refused (SSRF) unless the deployment opts out. Size-capped (the caller
159
+ # picks the ceiling; the default is the audio one).
160
+ def self.fetch_binary(url, max_bytes: MAX_AUDIO_BYTES)
161
+ violation = Insika::EgressGuard.violation(url, **egress_opt_out)
162
+ raise Insika::MediaError, "media egress blocked for #{url}: #{violation}" if violation
163
+
164
+ uri = URI.parse(url)
165
+ opts = { use_ssl: uri.scheme == "https", open_timeout: 30, read_timeout: 60 }
166
+ Net::HTTP.start(uri.host, uri.port, opts) do |http|
167
+ buf = +"".b
168
+ http.request(Net::HTTP::Get.new(uri)) do |resp|
169
+ raise Insika::MediaError, "media fetch HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
170
+
171
+ resp.read_body { |chunk| buf << chunk; break if buf.bytesize > max_bytes }
172
+ end
173
+ raise Insika::MediaError, "media exceeds #{max_bytes} bytes" if buf.bytesize > max_bytes
174
+ buf
175
+ end
176
+ rescue URI::InvalidURIError
177
+ raise Insika::MediaError, "invalid media URL"
178
+ end
179
+
180
+ # The opt-out the comment above promises, read from the SAME env the
181
+ # data-tool guard reads (INSIKA_EGRESS_ALLOW_HTTP / _ALLOW_PRIVATE): without
182
+ # this, a local run serving media over http:// ALWAYS failed, however the
183
+ # deployment was configured. INSIKA_EGRESS_HOSTS is deliberately NOT applied:
184
+ # that allowlist pins the handful of hosts a tool may call, while media URLs
185
+ # come from the channel's CDN — honouring it here would break every real
186
+ # deployment that narrows its tools.
187
+ def self.egress_opt_out
188
+ { allow_http: Insika::EnvSchema.truthy?(ENV["INSIKA_EGRESS_ALLOW_HTTP"]),
189
+ allow_private: Insika::EnvSchema.truthy?(ENV["INSIKA_EGRESS_ALLOW_PRIVATE"]) }
190
+ end
191
+
192
+ # WS9 (saída): generated media. The OUTPUT shape is an additive part —
193
+ # `{ "type": "image"|"audio", "mime_type": …, "base64": …, "model": … }`
194
+ # — that rides the turn's `output_parts` (terminal event + /v1/responses
195
+ # envelope), NEVER the answer text: the customer's channel consumes the
196
+ # bytes, the model's prose stays the answer.
197
+ #
198
+ # The GENERATION SEAMS are injectable like the STT seam: each is a
199
+ # `->(content, config) { [ part_hash, usage_hash ] }` (part_hash already
200
+ # carries its "type"), specs stub them, and the defaults hit the provider
201
+ # behind lazy requires:
202
+ # · image — RubyLLM.paint (the gem has vision AND painting), billed
203
+ # tokens merged into the turn's usage like any ask;
204
+ # · tts — RubyLLM still has NO speech API (as of 1.16.0), so the default
205
+ # is a thin POST to the OpenAI-compatible `<base>/audio/speech`
206
+ # endpoint (base + key from the provider config the chat uses — a
207
+ # deployment pointing OpenAI at a gateway keeps TTS pointing there).
208
+ # OpenAI's speech API reports no token usage; the part carries the
209
+ # model so the consumer can price it, and the turn counts the call.
210
+ module Output
211
+ DEFAULT_IMAGE_SIZE = "1024x1024"
212
+ DEFAULT_TTS_MODEL = "tts-1"
213
+ DEFAULT_TTS_VOICE = "alloy"
214
+ DEFAULT_TTS_FORMAT = "mp3"
215
+ # Base64 inlines into the envelope — a cap so a pathological generation
216
+ # cannot blow up the SSE frame. A generated 1024x1024 PNG sits well under.
217
+ MAX_EMBEDDED_BYTES = 8 * 1024 * 1024
218
+ # Edit sources on one `paint(with:)` call — a fitting room needs a
219
+ # handful of angles, not a gallery.
220
+ MAX_SOURCE_IMAGES = 4
221
+
222
+ class << self
223
+ # -> { image: seam, tts: seam } with the DEFAULTS bound to a context
224
+ # (the graph's RubyLLM::Context when it owns credentials — nil = the
225
+ # process-wide RubyLLM constant). Built lazily on first generation so
226
+ # the core loads without ruby_llm (load-guard).
227
+ def defaults(context:)
228
+ {
229
+ image: ->(prompt, config) { generate_image(prompt, config: config, context: context) },
230
+ tts: ->(text, config) { synthesize_speech(text, config: config, context: context) }
231
+ }
232
+ end
233
+
234
+ # -> [Part, usage]: paint via RubyLLM — text-to-image when the config
235
+ # carries no sources (byte-identical to before this call), image
236
+ # EDITING when it does: `source_urls`/`source_attachments` ride
237
+ # `paint(with:)`, `mask_url` rides `paint(mask:)`. usage is the
238
+ # provider's token counts ({ input_tokens:, output_tokens: } — merged
239
+ # into the turn's usage by the Executor); a provider without counts
240
+ # reports nothing.
241
+ def generate_image(prompt, config:, context:)
242
+ require "ruby_llm" # lazy — the core loads without it (load-guard)
243
+
244
+ cfg = Insika::Coercion.deep_stringify(config || {})
245
+ model = Insika::Coercion.presence(cfg["model"]) || image_model(context)
246
+ # `assume_model_exists` is deliberately NOT passed: RubyLLM raises
247
+ # ArgumentError when it's true without an explicit `provider` (see
248
+ # ModelSelection#assume_model_exists?), and `model` here is a bare
249
+ # ref like `utility_model` elsewhere — the registry resolves it.
250
+ api = context || RubyLLM
251
+ image = api.paint(prompt.to_s, model: model,
252
+ size: presence(cfg["size"]) || DEFAULT_IMAGE_SIZE,
253
+ with: source_attachments(cfg), mask: mask_attachment(cfg))
254
+ data = image.respond_to?(:data) ? image.data : nil
255
+ raise Insika::MediaError, "image generation returned no embeddable data" if data.to_s.empty?
256
+
257
+ enforce_embedded_size!(data, "generated image")
258
+ mime = image.respond_to?(:mime_type) ? image.mime_type : nil
259
+ model_id = image.respond_to?(:model_id) ? image.model_id : nil
260
+ usage = image.respond_to?(:usage) ? token_usage(image.usage) : {}
261
+ part = { "type" => "image", "mime_type" => presence(mime) || "image/png",
262
+ "base64" => data, "model" => presence(model_id) }
263
+ [part.compact, usage]
264
+ end
265
+
266
+ # -> [Part, {}]: synthesize speech via the OpenAI-compatible
267
+ # `<base>/audio/speech` endpoint. `context` supplies the base URL + key
268
+ # (the same config the chat uses — see `speech_endpoint`). The bytes
269
+ # embed base64 in the part; the usage is empty (no token counts on the
270
+ # speech API) and the part carries the model for consumer-side pricing.
271
+ def synthesize_speech(text, config:, context:)
272
+ require "net/http"
273
+ require "uri"
274
+ require "json"
275
+ require "base64"
276
+
277
+ cfg = Insika::Coercion.deep_stringify(config || {})
278
+ model = presence(cfg["model"]) || DEFAULT_TTS_MODEL
279
+ voice = presence(cfg["voice"]) || DEFAULT_TTS_VOICE
280
+ format = presence(cfg["format"]) || DEFAULT_TTS_FORMAT
281
+ base, key = speech_endpoint(context)
282
+ if key.to_s.empty?
283
+ raise Insika::MediaError,
284
+ "TTS needs an OpenAI API key (provider config) — set it on the " \
285
+ "provider the agent uses, or inject a tts seam"
286
+ end
287
+
288
+ uri = URI.parse("#{base}/audio/speech")
289
+ req = Net::HTTP::Post.new(uri)
290
+ req["Authorization"] = "Bearer #{key}"
291
+ req["Content-Type"] = "application/json"
292
+ req.body = JSON.generate(model: model, voice: voice, input: text.to_s,
293
+ response_format: format)
294
+ opts = { use_ssl: uri.scheme == "https", open_timeout: 30, read_timeout: 60 }
295
+ bytes = Net::HTTP.start(uri.host, uri.port, opts) do |http|
296
+ resp = http.request(req)
297
+ raise Insika::MediaError, "TTS HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
298
+
299
+ # stream into the cap — a rogue/broken endpoint must not grow the
300
+ # process past MAX_EMBEDDED_BYTES before the refusal.
301
+ buf = +"".b
302
+ resp.read_body do |chunk|
303
+ buf << chunk
304
+ break if buf.bytesize > MAX_EMBEDDED_BYTES
305
+ end
306
+ buf
307
+ end
308
+ enforce_embedded_size!(bytes, "synthesized speech")
309
+ part = { "type" => "audio", "mime_type" => mime_for(format), "base64" => Base64.strict_encode64(bytes), "model" => model }
310
+ [part.compact, {}]
311
+ rescue URI::InvalidURIError
312
+ raise Insika::MediaError, "invalid TTS endpoint"
313
+ end
314
+
315
+ private
316
+
317
+ # The OpenAI-compatible base URL + key behind the chat's provider
318
+ # config. A RubyLLM::Context owns the deployment's credentials; the
319
+ # global config is the fallback (a graph without its own context).
320
+ def speech_endpoint(context)
321
+ config = context.respond_to?(:config) ? context.config : RubyLLM.config
322
+ base = config.respond_to?(:openai_api_base) ? config.openai_api_base : nil
323
+ key = config.respond_to?(:openai_api_key) ? config.openai_api_key : nil
324
+ [presence(base) || "https://api.openai.com/v1", key]
325
+ end
326
+
327
+ def image_model(context)
328
+ config = context.respond_to?(:config) ? context.config : RubyLLM.config
329
+ config.respond_to?(:default_image_model) ? config.default_image_model : nil
330
+ end
331
+
332
+ # -> [Attachment] | nil: the edit sources for `paint(with:)`. Pre-built
333
+ # attachments win (the tool's DEFAULT source — the turn's own inbound
334
+ # images, already fetched bytes, no URL round-trip); otherwise explicit
335
+ # `source_urls` are fetched through the SAME capped, egress-guarded
336
+ # path images always used. nil (never []) when there are none — a
337
+ # provider without OpenAI's `editing?` leniency raises on a non-nil
338
+ # `with:`, and a text-to-image turn must stay byte-identical.
339
+ def source_attachments(cfg)
340
+ built = cfg["source_attachments"]
341
+ return built if built.is_a?(Array) && built.any?
342
+
343
+ urls = Array(cfg["source_urls"]).map(&:to_s).reject(&:empty?).first(MAX_SOURCE_IMAGES)
344
+ urls.empty? ? nil : urls.map { |u| Insika::Media.url_attachment(u, max_bytes: Insika::Media::MAX_IMAGE_BYTES) }
345
+ end
346
+
347
+ # -> Attachment | nil: the optional edit mask for `paint(mask:)`.
348
+ def mask_attachment(cfg)
349
+ url = presence(cfg["mask_url"])
350
+ return nil unless url
351
+
352
+ Insika::Media.url_attachment(url, max_bytes: Insika::Media::MAX_IMAGE_BYTES)
353
+ end
354
+
355
+ def token_usage(raw)
356
+ usage = raw.is_a?(Hash) ? raw : {}
357
+ {
358
+ input_tokens: usage[:input_tokens] || usage["input_tokens"] || usage["prompt_tokens"],
359
+ output_tokens: usage[:output_tokens] || usage["output_tokens"] || usage["completion_tokens"]
360
+ }.compact
361
+ end
362
+
363
+ def mime_for(format)
364
+ { "mp3" => "audio/mpeg", "opus" => "audio/opus", "aac" => "audio/aac",
365
+ "wav" => "audio/wav", "flac" => "audio/flac" }[format.to_s] || "audio/mpeg"
366
+ end
367
+
368
+ def enforce_embedded_size!(data, label)
369
+ size = data.respond_to?(:bytesize) ? data.bytesize : data.to_s.bytesize
370
+ return if size <= MAX_EMBEDDED_BYTES
371
+
372
+ raise Insika::MediaError,
373
+ "#{label} too large to embed (#{size} bytes > #{MAX_EMBEDDED_BYTES})"
374
+ end
375
+
376
+ def presence(value)
377
+ Insika::Coercion.presence(value)
378
+ end
379
+ end
380
+ end
381
+ end
382
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha2"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Insika
8
+ # append-only, per-cell audit of MEMORY mutations. Every
9
+ # operator mutation of a cell appends a line (who, when, what, old->new
10
+ # digests). The entry holds DIGESTS of values, never the values — the forget
11
+ # line records that a deletion happened without the deleted content (the
12
+ # digest is not invertible, and keys are fact names — provenance, not
13
+ # payload). Capped per cell (no retention hook, no delete path: the audit
14
+ # outlives what it describes). `record` rescues EVERYTHING — a failed audit
15
+ # write never fails the mutation it describes.
16
+ #
17
+ # The capped-list RMW caveat (context_trace_store.rb's discipline): one cell
18
+ # key, written on the command's fiber; a cross-process race loses the loser's
19
+ # append — a trace-level loss, not a correctness one.
20
+ class MemoryAuditStore
21
+ SCOPE = "memory_audit" # store key = the memory cell scope
22
+ MAX_PER_CELL = 200 # oldest dropped; the cap bounds growth
23
+
24
+ Entry = Data.define(:at, :action, :actor, :key, :tenant, :customer,
25
+ :old_hash, :new_hash, :note)
26
+
27
+ def initialize(store:, clock: nil)
28
+ @store = store
29
+ @clock = clock # -> Time, injectable for specs
30
+ end
31
+
32
+ # action: "put" | "forget" | "purge". key = the fact name (provenance,
33
+ # not payload). Appends + caps. Rescues EVERYTHING -> Entry | nil (the
34
+ # audit never breaks a command).
35
+ def record(cell:, action:, actor:, key: nil, tenant: nil, customer: nil,
36
+ old_hash: nil, new_hash: nil, note: nil)
37
+ entry = {
38
+ "at" => timestamp,
39
+ "action" => action.to_s,
40
+ "actor" => actor.to_s,
41
+ "key" => Coercion.presence(key),
42
+ "tenant" => Coercion.presence(tenant),
43
+ "customer" => Coercion.presence(customer),
44
+ "old_hash" => Coercion.presence(old_hash),
45
+ "new_hash" => Coercion.presence(new_hash),
46
+ "note" => Coercion.presence(note)
47
+ }
48
+ list = (@store.get(SCOPE, cell.to_s) || []) + [entry]
49
+ @store.set(SCOPE, cell.to_s, list.last(MAX_PER_CELL))
50
+ to_entry(entry)
51
+ rescue StandardError
52
+ nil
53
+ end
54
+
55
+ # -> [Entry] most recent first. [] if none. A broken backend degrades to
56
+ # [] — the audit is read to RENDER, never to gate.
57
+ def for_cell(cell, limit: 100)
58
+ Array(@store.get(SCOPE, cell.to_s)).reverse.first(limit).map { |e| to_entry(e) }
59
+ rescue StandardError
60
+ []
61
+ end
62
+
63
+ # The digest the callers share: SHA-256 hexdigest of JSON.generate(value)
64
+ # (or value.to_s for non-JSON scalars). -> String
65
+ def self.digest(value)
66
+ payload = case value
67
+ when Hash, Array then JSON.generate(value)
68
+ else value.to_s
69
+ end
70
+ Digest::SHA256.hexdigest(payload)
71
+ end
72
+
73
+ private
74
+
75
+ def to_entry(record)
76
+ Entry.new(at: record["at"], action: record["action"], actor: record["actor"],
77
+ key: record["key"], tenant: record["tenant"], customer: record["customer"],
78
+ old_hash: record["old_hash"], new_hash: record["new_hash"], note: record["note"])
79
+ end
80
+
81
+ def timestamp = (clock ? clock.call : Time.now.utc).utc.iso8601(6)
82
+
83
+ def clock = @clock
84
+ end
85
+ end