insika 0.0.1 → 0.1.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 (260) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +295 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +351 -0
  6. data/docs/AGENTS.md +494 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +100 -0
  11. data/docs/DEPLOY.md +334 -0
  12. data/docs/EMBEDDING.md +194 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +231 -0
  15. data/docs/OBSERVABILITY.md +365 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +362 -0
  22. data/docs/SKILLS.md +98 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +188 -0
  34. data/lib/insika/allowlist.rb +28 -0
  35. data/lib/insika/baseline_store.rb +74 -0
  36. data/lib/insika/capability/resolved_tool.rb +34 -0
  37. data/lib/insika/capability_registry.rb +112 -0
  38. data/lib/insika/channel_delivery.rb +150 -0
  39. data/lib/insika/channel_registry.rb +30 -0
  40. data/lib/insika/channels/relay.rb +178 -0
  41. data/lib/insika/channels/web/widget.js +283 -0
  42. data/lib/insika/channels/web.rb +211 -0
  43. data/lib/insika/chat_builder.rb +254 -0
  44. data/lib/insika/checkpoint.rb +13 -0
  45. data/lib/insika/checkpoint_store.rb +153 -0
  46. data/lib/insika/coercion.rb +50 -0
  47. data/lib/insika/command.rb +32 -0
  48. data/lib/insika/command_bus.rb +39 -0
  49. data/lib/insika/commands/agent_payload.rb +41 -0
  50. data/lib/insika/commands/approve_action.rb +46 -0
  51. data/lib/insika/commands/cancel_task.rb +33 -0
  52. data/lib/insika/commands/create_agent.rb +54 -0
  53. data/lib/insika/commands/create_session.rb +67 -0
  54. data/lib/insika/commands/delete_agent.rb +33 -0
  55. data/lib/insika/commands/delete_agent_file.rb +50 -0
  56. data/lib/insika/commands/delete_data_tool.rb +33 -0
  57. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  58. data/lib/insika/commands/delete_mcp.rb +30 -0
  59. data/lib/insika/commands/delete_system_file.rb +29 -0
  60. data/lib/insika/commands/gate_refinement.rb +245 -0
  61. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  62. data/lib/insika/commands/import_tools.rb +81 -0
  63. data/lib/insika/commands/memory_add_note.rb +32 -0
  64. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  65. data/lib/insika/commands/memory_put_fact.rb +35 -0
  66. data/lib/insika/commands/pause_task.rb +29 -0
  67. data/lib/insika/commands/resolve_refinement.rb +126 -0
  68. data/lib/insika/commands/restore_agent_file.rb +36 -0
  69. data/lib/insika/commands/restore_data_tool.rb +34 -0
  70. data/lib/insika/commands/restore_system_file.rb +31 -0
  71. data/lib/insika/commands/resume_task.rb +85 -0
  72. data/lib/insika/commands/run_refinement.rb +133 -0
  73. data/lib/insika/commands/send_message.rb +150 -0
  74. data/lib/insika/commands/set_agent_tools.rb +39 -0
  75. data/lib/insika/commands/set_skill_agents.rb +71 -0
  76. data/lib/insika/commands/trigger_workflow.rb +80 -0
  77. data/lib/insika/commands/update_agent.rb +49 -0
  78. data/lib/insika/commands/update_settings.rb +33 -0
  79. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  80. data/lib/insika/commands/upsert_mcp.rb +32 -0
  81. data/lib/insika/commands/write_agent_file.rb +57 -0
  82. data/lib/insika/commands/write_data_tool.rb +43 -0
  83. data/lib/insika/commands/write_golden.rb +58 -0
  84. data/lib/insika/commands/write_skill.rb +50 -0
  85. data/lib/insika/commands/write_system_file.rb +31 -0
  86. data/lib/insika/config_store.rb +85 -0
  87. data/lib/insika/context/builder.rb +166 -0
  88. data/lib/insika/context/catalog_provider.rb +23 -0
  89. data/lib/insika/context/fragment.rb +19 -0
  90. data/lib/insika/context/priority.rb +29 -0
  91. data/lib/insika/context/provider.rb +19 -0
  92. data/lib/insika/context/providers/memory.rb +60 -0
  93. data/lib/insika/context/providers/prompt.rb +105 -0
  94. data/lib/insika/context/providers/request.rb +32 -0
  95. data/lib/insika/context/providers/session.rb +108 -0
  96. data/lib/insika/context/providers/skill.rb +20 -0
  97. data/lib/insika/context/providers/tool_search.rb +20 -0
  98. data/lib/insika/delegation_store.rb +153 -0
  99. data/lib/insika/doctor.rb +294 -0
  100. data/lib/insika/dsl/definition.rb +55 -0
  101. data/lib/insika/dsl/runtime.rb +379 -0
  102. data/lib/insika/dsl/server_boot.rb +97 -0
  103. data/lib/insika/dsl/system.rb +93 -0
  104. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  105. data/lib/insika/dsl.rb +307 -0
  106. data/lib/insika/edge_limiter.rb +130 -0
  107. data/lib/insika/egress_guard.rb +75 -0
  108. data/lib/insika/env_schema.rb +246 -0
  109. data/lib/insika/errors.rb +145 -0
  110. data/lib/insika/evals/assertions.rb +247 -0
  111. data/lib/insika/evals/baseline.rb +69 -0
  112. data/lib/insika/evals/golden.rb +172 -0
  113. data/lib/insika/evals/judge.rb +225 -0
  114. data/lib/insika/evals/pairwise.rb +178 -0
  115. data/lib/insika/evals/report.rb +115 -0
  116. data/lib/insika/evals/runner.rb +141 -0
  117. data/lib/insika/evals/transport.rb +178 -0
  118. data/lib/insika/event.rb +18 -0
  119. data/lib/insika/event_stream.rb +114 -0
  120. data/lib/insika/executor.rb +1680 -0
  121. data/lib/insika/frontmatter.rb +42 -0
  122. data/lib/insika/golden_store.rb +145 -0
  123. data/lib/insika/hooks.rb +48 -0
  124. data/lib/insika/http_client.rb +63 -0
  125. data/lib/insika/inbound_log.rb +84 -0
  126. data/lib/insika/llm_configurator.rb +99 -0
  127. data/lib/insika/llm_provider_store.rb +83 -0
  128. data/lib/insika/mcp_http_client.rb +67 -0
  129. data/lib/insika/mcp_store.rb +115 -0
  130. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  131. data/lib/insika/memory_store.rb +93 -0
  132. data/lib/insika/message_origin.rb +76 -0
  133. data/lib/insika/middleware.rb +36 -0
  134. data/lib/insika/model_policy.rb +52 -0
  135. data/lib/insika/model_resolver.rb +176 -0
  136. data/lib/insika/model_selection.rb +114 -0
  137. data/lib/insika/onboarding.rb +208 -0
  138. data/lib/insika/outbox_store.rb +166 -0
  139. data/lib/insika/overlay_tool_registry.rb +103 -0
  140. data/lib/insika/pack.rb +102 -0
  141. data/lib/insika/pack_importer.rb +121 -0
  142. data/lib/insika/pending_action_store.rb +120 -0
  143. data/lib/insika/plugin/loader.rb +356 -0
  144. data/lib/insika/plugin.rb +35 -0
  145. data/lib/insika/policy/engine.rb +83 -0
  146. data/lib/insika/policy/policy.rb +120 -0
  147. data/lib/insika/policy_registry.rb +23 -0
  148. data/lib/insika/profile_source.rb +137 -0
  149. data/lib/insika/prompt_catalog.rb +61 -0
  150. data/lib/insika/queue_policy.rb +167 -0
  151. data/lib/insika/recovery.rb +127 -0
  152. data/lib/insika/refinement/candidate.rb +159 -0
  153. data/lib/insika/refinement/evidence_collector.rb +371 -0
  154. data/lib/insika/refinement/gate.rb +234 -0
  155. data/lib/insika/refinement/panel.rb +222 -0
  156. data/lib/insika/refinement/proposer.rb +262 -0
  157. data/lib/insika/refinement_store.rb +295 -0
  158. data/lib/insika/registry.rb +59 -0
  159. data/lib/insika/safety/config.rb +109 -0
  160. data/lib/insika/safety/detectors.rb +176 -0
  161. data/lib/insika/safety/factory.rb +102 -0
  162. data/lib/insika/safety/input_guardrail.rb +87 -0
  163. data/lib/insika/safety/moderator.rb +86 -0
  164. data/lib/insika/safety/output_filter.rb +79 -0
  165. data/lib/insika/safety/output_validator.rb +101 -0
  166. data/lib/insika/safety/safe_responses.rb +47 -0
  167. data/lib/insika/sandbox/boundary.rb +93 -0
  168. data/lib/insika/sandbox/docker.rb +74 -0
  169. data/lib/insika/sandbox/local.rb +33 -0
  170. data/lib/insika/sandbox/runner.rb +80 -0
  171. data/lib/insika/sandbox.rb +85 -0
  172. data/lib/insika/schema_guard.rb +147 -0
  173. data/lib/insika/secret_masking.rb +34 -0
  174. data/lib/insika/server/a2a/agent_card.rb +27 -0
  175. data/lib/insika/server/a2a/app.rb +112 -0
  176. data/lib/insika/server/a2a/client.rb +101 -0
  177. data/lib/insika/server/a2a/errors.rb +32 -0
  178. data/lib/insika/server/a2a/http.rb +42 -0
  179. data/lib/insika/server/a2a/message.rb +27 -0
  180. data/lib/insika/server/a2a/protocol.rb +45 -0
  181. data/lib/insika/server/a2a/remotes.rb +25 -0
  182. data/lib/insika/server/a2a/task_projection.rb +40 -0
  183. data/lib/insika/server/admin_auth.rb +29 -0
  184. data/lib/insika/server/app.rb +850 -0
  185. data/lib/insika/server/boot.rb +119 -0
  186. data/lib/insika/server/rack_app.rb +110 -0
  187. data/lib/insika/server/responses.rb +155 -0
  188. data/lib/insika/server/sse_body.rb +96 -0
  189. data/lib/insika/session_actor.rb +162 -0
  190. data/lib/insika/session_store.rb +143 -0
  191. data/lib/insika/settings_store.rb +154 -0
  192. data/lib/insika/shutdown.rb +125 -0
  193. data/lib/insika/skill_catalog.rb +113 -0
  194. data/lib/insika/skill_store.rb +79 -0
  195. data/lib/insika/steer_injector.rb +110 -0
  196. data/lib/insika/store.rb +52 -0
  197. data/lib/insika/stores/memory.rb +123 -0
  198. data/lib/insika/stores/sqlite.rb +183 -0
  199. data/lib/insika/studio/app.rb +1571 -0
  200. data/lib/insika/studio/assets/dist/application.css +1 -0
  201. data/lib/insika/studio/assets/dist/application.js +69 -0
  202. data/lib/insika/studio/forms.rb +340 -0
  203. data/lib/insika/studio/nav_icons.rb +31 -0
  204. data/lib/insika/studio/views/_message.erb +44 -0
  205. data/lib/insika/studio/views/agent_detail.erb +285 -0
  206. data/lib/insika/studio/views/agents.erb +63 -0
  207. data/lib/insika/studio/views/approvals.erb +41 -0
  208. data/lib/insika/studio/views/chats.erb +34 -0
  209. data/lib/insika/studio/views/evals.erb +83 -0
  210. data/lib/insika/studio/views/home.erb +72 -0
  211. data/lib/insika/studio/views/layout.erb +94 -0
  212. data/lib/insika/studio/views/login.erb +17 -0
  213. data/lib/insika/studio/views/mcp.erb +91 -0
  214. data/lib/insika/studio/views/not_found.erb +5 -0
  215. data/lib/insika/studio/views/playground.erb +47 -0
  216. data/lib/insika/studio/views/refinement.erb +234 -0
  217. data/lib/insika/studio/views/session.erb +62 -0
  218. data/lib/insika/studio/views/settings.erb +173 -0
  219. data/lib/insika/studio/views/skills.erb +86 -0
  220. data/lib/insika/studio/views/system_files.erb +65 -0
  221. data/lib/insika/studio/views/task.erb +105 -0
  222. data/lib/insika/studio/views/tasks.erb +33 -0
  223. data/lib/insika/studio/views/tool_edit.erb +107 -0
  224. data/lib/insika/studio/views/tools.erb +89 -0
  225. data/lib/insika/subagent_graph.rb +96 -0
  226. data/lib/insika/system_file_store.rb +96 -0
  227. data/lib/insika/task_actor.rb +128 -0
  228. data/lib/insika/task_store.rb +250 -0
  229. data/lib/insika/telemetry/pricing.rb +104 -0
  230. data/lib/insika/telemetry/recorder.rb +228 -0
  231. data/lib/insika/telemetry.rb +127 -0
  232. data/lib/insika/testing/store_contract.rb +270 -0
  233. data/lib/insika/token_estimator.rb +16 -0
  234. data/lib/insika/tool_assembly.rb +140 -0
  235. data/lib/insika/tool_catalog.rb +89 -0
  236. data/lib/insika/tool_definition.rb +518 -0
  237. data/lib/insika/tool_envelope.rb +140 -0
  238. data/lib/insika/tool_manifest.rb +218 -0
  239. data/lib/insika/tool_registry.rb +21 -0
  240. data/lib/insika/tool_store.rb +135 -0
  241. data/lib/insika/tool_trace_store.rb +92 -0
  242. data/lib/insika/tools/a2a_remote.rb +48 -0
  243. data/lib/insika/tools/agent_enum.rb +68 -0
  244. data/lib/insika/tools/concurrency.rb +54 -0
  245. data/lib/insika/tools/data_defined_tool.rb +220 -0
  246. data/lib/insika/tools/load_skill.rb +41 -0
  247. data/lib/insika/tools/remember.rb +53 -0
  248. data/lib/insika/tools/subagent.rb +75 -0
  249. data/lib/insika/tools/subagents.rb +77 -0
  250. data/lib/insika/tools/tool_search.rb +94 -0
  251. data/lib/insika/turn_output.rb +139 -0
  252. data/lib/insika/turn_state.rb +158 -0
  253. data/lib/insika/turn_timing.rb +56 -0
  254. data/lib/insika/usage_ledger.rb +47 -0
  255. data/lib/insika/version.rb +3 -1
  256. data/lib/insika/wiring/graph.rb +198 -0
  257. data/lib/insika/workflow.rb +185 -0
  258. data/lib/insika/workflow_registry.rb +33 -0
  259. data/lib/insika.rb +203 -4
  260. metadata +395 -8
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # REFINEMENT DOMAIN store (RFC-0013, phase A). One record per refinement RUN:
8
+ # the window that was read, the ranked findings the EvidenceCollector produced,
9
+ # and the run's outcome. RUNTIME data (it is derived from sessions/tasks/traces),
10
+ # so it takes the raw `store:` like SessionStore/TaskStore — not the ConfigStore.
11
+ #
12
+ # The key embeds the agent and the start timestamp:
13
+ # "run:<agent_id>:<started_at>:<id>"
14
+ # so `list(SCOPE, "run:<agent>:")` comes back CHRONOLOGICAL for that agent (the
15
+ # Store contract orders lexicographically) and `latest_for` is its last element.
16
+ # An agent id containing ":" would break that split, so it is rejected on write.
17
+ #
18
+ # Phase A wrote no edits anywhere — a Run was a REPORT and every non-collecting
19
+ # status was terminal. Phase C (RFC-0013 §3.2) adds the rest of the lifecycle on
20
+ # the SAME record, additively: a gated candidate and the operator's decision.
21
+ #
22
+ # collecting ─▶ completed ─▶ gating ─▶ awaiting_approval ─▶ applied
23
+ # ╰─▶ no_findings ╰─▶ rejected (the gate failed it,
24
+ # ╰─▶ failed or the operator did)
25
+ #
26
+ # **The approval lives here, not in `PendingActionStore`** — a deliberate deviation
27
+ # from §3.6. That store is coupled to a suspended TURN: `ApproveAction` resolves
28
+ # the record and then calls `executor.approve(task_id)` to wake a fiber. A
29
+ # refinement proposal has no turn and no fiber, so reusing it would mean inventing
30
+ # a task id, a fake `tool` name, and a wake-up that must never do anything — and
31
+ # the operator's approvals inbox would fill with rows that are not tool calls. The
32
+ # property §3.6 actually wanted is durability across a `kill -9`, and this store
33
+ # has had it since phase A.
34
+ class RefinementStore
35
+ include Coercion
36
+
37
+ SCOPE = "refinements"
38
+ KEY_PREFIX = "run:"
39
+
40
+ STATUSES = %i[collecting completed gating awaiting_approval applied rejected
41
+ no_findings failed].freeze
42
+
43
+ # OPEN means "this run will change without anyone asking": work is in flight
44
+ # (`collecting`, `gating`) or a human owes it an answer (`awaiting_approval`).
45
+ # Everything else is terminal — INCLUDING `completed`, which phase A already
46
+ # treated that way and which stays true: a report is finished, and gating one is
47
+ # a new deliberate action, not a continuation. (Widening `terminal?` here is what
48
+ # the Studio's "latest report" lookup reads, so getting it wrong hides the report.)
49
+ OPEN = %i[collecting gating awaiting_approval].freeze
50
+
51
+ # `candidate`/`gate` are the WINNER — the one proposal a human is asked about, and
52
+ # the only thing `ResolveRefinement` ever applies. `candidates` is the whole panel
53
+ # (RFC-0013 §3.9, phase D): every candidate that was built, who wrote it, and how
54
+ # it scored, including the ones that lost and the ones the budget never gated. A
55
+ # phase-C run recorded one candidate and no panel; it still reads back correctly,
56
+ # because a panel of one is the same shape.
57
+ Run = Data.define(:id, :agent_id, :status, :window, :findings, :excluded,
58
+ :started_at, :finished_at, :error,
59
+ :candidate, :candidates, :gate, :cost, :decision) do
60
+ def terminal? = !OPEN.include?(status)
61
+ def findings_count = findings.size
62
+
63
+ # Is there a gated proposal waiting for a human? The one question the Studio
64
+ # and the apply command both ask.
65
+ def awaiting_approval? = status == :awaiting_approval
66
+ def gate_passed? = gate.is_a?(Hash) && gate["passed"] == true
67
+ def edits = (candidate || {})["edits"] || []
68
+ def panel = Array(candidates)
69
+ end
70
+
71
+ def initialize(store:)
72
+ @store = store
73
+ end
74
+
75
+ # Opens a run (:collecting). `window` is the collector's window as data
76
+ # ({ "last_sessions" => N } | { "since" => iso8601 }) — recorded so a report can
77
+ # be read months later and still say what it looked at. -> Run.
78
+ def create(agent_id:, window: {}, id: SecureRandom.uuid, at: nil)
79
+ agent = agent_id.to_s
80
+ raise Insika::ValidationError, "agent_id is required" if agent.empty?
81
+ raise Insika::ValidationError, "agent_id must not contain ':'" if agent.include?(":")
82
+
83
+ started = at || timestamp
84
+ record = {
85
+ "id" => id.to_s, "agent_id" => agent, "status" => "collecting",
86
+ "window" => deep_stringify(window || {}), "findings" => [], "excluded" => 0,
87
+ "started_at" => started, "finished_at" => nil, "error" => nil,
88
+ "candidate" => nil, "candidates" => [], "gate" => nil, "cost" => nil,
89
+ "decision" => nil
90
+ }
91
+ @store.set(SCOPE, key_for(agent, started, id), record)
92
+ to_run(record)
93
+ end
94
+
95
+ # Closes a run with its findings. Empty findings -> :no_findings (a distinct
96
+ # outcome from :completed — "we looked and it was clean" is a real answer, not a
97
+ # failure). -> Run. ArgumentError if the run is already terminal.
98
+ # `excluded` is how many turns the window dropped on purpose (synthetic
99
+ # traffic) — recorded so a report never reads cleaner than the data was.
100
+ def complete(id, findings:, excluded: 0)
101
+ update(id) do |record|
102
+ guard_open!(record)
103
+ list = Array(findings).map { |f| deep_stringify(f.respond_to?(:to_h) ? f.to_h : f) }
104
+ record["findings"] = list
105
+ record["excluded"] = Integer(excluded)
106
+ record["status"] = list.empty? ? "no_findings" : "completed"
107
+ record["finished_at"] = timestamp
108
+ end
109
+ end
110
+
111
+ # Closes a run as :failed, recording the error. -> Run.
112
+ def fail(id, error:)
113
+ update(id) do |record|
114
+ guard_open!(record)
115
+ record["status"] = "failed"
116
+ record["error"] = error.to_s
117
+ record["finished_at"] = timestamp
118
+ end
119
+ end
120
+
121
+ # -- phase C: the proposal's lifecycle -------------------------------
122
+
123
+ # Attaches the candidate(s) under gate and moves the run to :gating. Only a
124
+ # `completed` run can be gated: a report with no findings has nothing to propose
125
+ # from, and a failed one never finished looking.
126
+ #
127
+ # `candidate:` (one) and `candidates:` (a panel) are the same call — the panel is
128
+ # recorded before the gate runs so the Studio can show WHAT is being scored while
129
+ # it is being scored, which is minutes of real replay. No winner is claimed yet:
130
+ # `candidate` stays nil until `gated` says which one it is. -> Run.
131
+ def gating(id, candidate: nil, candidates: nil)
132
+ panel = Array(candidates || [candidate].compact)
133
+ raise Insika::ValidationError, "a candidate is required to gate" if panel.empty?
134
+
135
+ update(id) do |record|
136
+ unless record["status"] == "completed"
137
+ raise ArgumentError, "run #{record['id']} is #{record['status']}, expected completed"
138
+ end
139
+
140
+ record["candidates"] = panel.map { |c| entry_for(c) }
141
+ record["candidate"] = nil
142
+ record["gate"] = nil
143
+ record["status"] = "gating"
144
+ end
145
+ end
146
+
147
+ # Records the gate's verdict. A PASS parks the run at :awaiting_approval — a
148
+ # human still has to say yes, which is the product and not a formality (D2). A
149
+ # FAIL is terminal as :rejected, with the gate report as the stated reason: the
150
+ # same finding must re-surface with new evidence before anything is proposed
151
+ # again, so there is no silent retry loop (§3.6).
152
+ #
153
+ # `report` is the WINNER's (or, when nothing survived, the most informative
154
+ # refusal). `panel` is every scored entry — the store attaches it as-is and picks
155
+ # the winning candidate out of it by id. Which candidate WON is the caller's
156
+ # ranking decision (§3.5); this store does not rank, it records. -> Run.
157
+ def gated(id, report:, panel: nil, cost: nil)
158
+ update(id) do |record|
159
+ unless record["status"] == "gating"
160
+ raise ArgumentError, "run #{record['id']} is #{record['status']}, expected gating"
161
+ end
162
+
163
+ gate = deep_stringify(report.respond_to?(:to_h) ? report.to_h : report)
164
+ record["candidates"] = panel.map { |e| entry_for(e) } if panel
165
+ record["candidate"] = winning_candidate(record, gate)
166
+ record["gate"] = gate
167
+ record["cost"] = deep_stringify(cost.respond_to?(:to_h) ? cost.to_h : cost) if cost
168
+ if gate["passed"]
169
+ record["status"] = "awaiting_approval"
170
+ else
171
+ record["status"] = "rejected"
172
+ record["decision"] = { "by" => "gate", "at" => timestamp, "note" => gate["reason"] }
173
+ record["finished_at"] = timestamp
174
+ end
175
+ end
176
+ end
177
+
178
+ # The operator's answer to a gated proposal. `applied` is recorded only after the
179
+ # writes land, so a crash between the two leaves the run awaiting approval and
180
+ # the operator re-approves — replaying a write that is already versioned and
181
+ # idempotent-ish beats recording a lie. -> Run.
182
+ def resolve(id, decision:, operator: nil, note: nil)
183
+ target = decision.to_sym
184
+ unless %i[applied rejected].include?(target)
185
+ raise Insika::ValidationError, "invalid decision: #{decision} (applied|rejected)"
186
+ end
187
+
188
+ update(id) do |record|
189
+ unless record["status"] == "awaiting_approval"
190
+ raise ArgumentError, "run #{record['id']} is #{record['status']}, expected awaiting_approval"
191
+ end
192
+
193
+ record["status"] = target.to_s
194
+ record["decision"] = { "by" => (Coercion.presence(operator) || "operator").to_s,
195
+ "at" => timestamp, "note" => Coercion.presence(note) }.compact
196
+ record["finished_at"] = timestamp
197
+ end
198
+ end
199
+
200
+ # -> [Run] every run parked on a human, most recent first. What the Studio badges.
201
+ def awaiting_approval(limit: 20)
202
+ recent(limit: 200).select(&:awaiting_approval?).first(limit)
203
+ end
204
+
205
+ # -> Run | nil. O(n) scan over the scope (the key carries agent+timestamp, so
206
+ # there is no index by id): one node, local SQLite, runs are operator-paced.
207
+ def find(id)
208
+ key = key_for_id(id)
209
+ key && to_run(@store.get(SCOPE, key))
210
+ end
211
+
212
+ # -> [Run] for one agent, MOST RECENT FIRST, capped by `limit`.
213
+ def for_agent(agent_id, limit: nil)
214
+ keys = @store.list(SCOPE, "#{KEY_PREFIX}#{agent_id}:").reverse
215
+ keys = keys.first(limit) if limit
216
+ keys.filter_map { |k| to_run(@store.get(SCOPE, k)) }
217
+ end
218
+
219
+ # -> Run | nil (the agent's most recent run, whatever its status).
220
+ def latest_for(agent_id) = for_agent(agent_id, limit: 1).first
221
+
222
+ # -> [Run] across every agent, most recent first, capped.
223
+ def recent(limit: 20)
224
+ @store.list(SCOPE, KEY_PREFIX)
225
+ .filter_map { |k| to_run(@store.get(SCOPE, k)) }
226
+ .sort_by { |r| r.started_at.to_s }.reverse.first(limit)
227
+ end
228
+
229
+ private
230
+
231
+ # One panel row: `{ candidate:, proposers: [], gate: }`. A bare Candidate (phase
232
+ # C, an operator's own payload, an older record) is wrapped into the same shape,
233
+ # so every reader has ONE thing to read and the two eras of this record do not
234
+ # each need a branch in the Studio.
235
+ def entry_for(value)
236
+ raw = deep_stringify(value.respond_to?(:to_h) ? value.to_h : value)
237
+ return raw if raw.key?("candidate")
238
+
239
+ { "candidate" => raw, "proposers" => [raw["proposer"]].compact, "gate" => nil }
240
+ end
241
+
242
+ # The candidate the winning report belongs to, matched by id. A report whose
243
+ # candidate is not in the panel (an older record, a hand-built payload) leaves
244
+ # whatever was already there rather than inventing a winner.
245
+ def winning_candidate(record, gate)
246
+ match = Array(record["candidates"]).find do |entry|
247
+ (entry["candidate"] || {})["id"] == gate["candidate_id"]
248
+ end
249
+ match ? match["candidate"] : record["candidate"]
250
+ end
251
+
252
+ def key_for(agent, started_at, id) = "#{KEY_PREFIX}#{agent}:#{started_at}:#{id}"
253
+
254
+ # The id is the key's last segment; scanning is the price of keeping the key
255
+ # chronological (which is what every read except `find` actually wants).
256
+ def key_for_id(id)
257
+ suffix = ":#{id}"
258
+ @store.list(SCOPE, KEY_PREFIX).find { |k| k.end_with?(suffix) }
259
+ end
260
+
261
+ def update(id)
262
+ key = key_for_id(id)
263
+ raise Insika::NotFoundError, "refinement run not found: #{id}" if key.nil?
264
+
265
+ record = @store.get(SCOPE, key)
266
+ raise Insika::NotFoundError, "refinement run not found: #{id}" if record.nil?
267
+
268
+ yield record
269
+ @store.set(SCOPE, key, record)
270
+ to_run(record)
271
+ end
272
+
273
+ def guard_open!(record)
274
+ return if record["status"] == "collecting"
275
+
276
+ raise ArgumentError, "run #{record['id']} is already #{record['status']}"
277
+ end
278
+
279
+ def to_run(record)
280
+ return nil if record.nil?
281
+
282
+ Run.new(
283
+ id: record["id"], agent_id: record["agent_id"],
284
+ status: record["status"].to_sym, window: record["window"] || {},
285
+ findings: record["findings"] || [], excluded: record["excluded"] || 0,
286
+ started_at: record["started_at"], finished_at: record["finished_at"],
287
+ error: record["error"],
288
+ candidate: record["candidate"], candidates: record["candidates"] || [],
289
+ gate: record["gate"], cost: record["cost"], decision: record["decision"]
290
+ )
291
+ end
292
+
293
+ def timestamp = Time.now.utc.iso8601
294
+ end
295
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Generic executable base: Registry =
5
+ # EXECUTABLE content (tools/workflows/policies). Catalog (skills/prompts) is
6
+ # non-executable and does not inherit from here.
7
+ #
8
+ # Immutable post-boot by CONSTRUCTION (only boot registers): there
9
+ # is no `freeze!`; immutability is not enforced at runtime.
10
+ class Registry
11
+ Entry = Data.define(:name, :plugin, :metadata, :factory)
12
+
13
+ def initialize
14
+ @entries = {}
15
+ end
16
+
17
+ # factory = block OR the positional callable. metadata captured by **kw
18
+ # (Symbol keys, stored as-is). Duplicate: FIRST wins (plugin precedence)
19
+ # — the second is discarded with a warn, never overwritten.
20
+ def register(name, callable = nil, plugin: nil, **metadata, &block)
21
+ name = name.to_s
22
+ factory = block || (callable.nil? ? nil : -> { callable })
23
+ raise ArgumentError, "registration without factory: #{name}" if factory.nil?
24
+
25
+ if @entries.key?(name)
26
+ existing = @entries[name]
27
+ warn "[registry] '#{name}' already registered by #{existing.plugin.inspect}; " \
28
+ "descartando registro de #{plugin.inspect} (primeiro vence)"
29
+ return self
30
+ end
31
+
32
+ @entries[name] = Entry.new(name: name, plugin: plugin&.to_s, metadata: metadata, factory: factory)
33
+ self
34
+ end
35
+
36
+ # -> instance (factory.call) | raise NotFoundError.
37
+ def resolve(name)
38
+ entry(name).factory.call
39
+ end
40
+
41
+ # -> the raw Entry (name/plugin/metadata/factory) | raise NotFoundError. For
42
+ # readers that need the metadata WITHOUT resolving the factory (discovery,
43
+ # WorkflowRegistry#definition).
44
+ def entry(name)
45
+ @entries[name.to_s] ||
46
+ (raise Insika::NotFoundError, "'#{name}' not registered in #{self.class}")
47
+ end
48
+
49
+ def entries = @entries.values
50
+ def names = @entries.keys
51
+
52
+ # Loader rollback support: removes the entries of a
53
+ # plugin. NOT a runtime API (registries are immutable post-boot).
54
+ def deregister_plugin(plugin_id)
55
+ @entries.delete_if { |_name, entry| entry.plugin == plugin_id.to_s }
56
+ nil
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Safety
5
+ # Per-agent guardrail configuration (RFC-0009 §3.3 / D6), read from
6
+ # `profile.guardrails`. OPT-IN like `capabilities`: an agent that says nothing
7
+ # gets the CONSERVATIVE default — deterministic detectors ON, LLM moderator OFF.
8
+ #
9
+ # Tolerant of the JSON round-trip (string OR symbol keys / values), same
10
+ # discipline as ModelPolicy — the StoredProfileSource persists this as a plain
11
+ # Hash and it comes back stringified.
12
+ #
13
+ # guardrails: {
14
+ # input: true|false, # run the input guardrail middleware (default true)
15
+ # output: true|false, # run the output filter + validator (default true)
16
+ # moderator: "provider/model"|nil, # LLM moderator model; nil = deterministic only
17
+ # strictness: "low"|"medium"|"high", # which input categories fire (default medium)
18
+ # responses: { <category> => "<safe reply>", ... } # per-agent override, see below
19
+ # }
20
+ #
21
+ # `responses` is the CONFIGURATION-OVER-CONVENTION knob (RFC-0009 §7). The engine
22
+ # ships neutral built-in refusals (Safety::SafeResponses::DEFAULTS), but this is
23
+ # OSS across arbitrary businesses/languages, so we never hard-bake tone: an agent
24
+ # overrides the safe reply per category (`injection`/`sexual`/`abuse`/`escalate`/
25
+ # …) or sets a single catch-all `default`. Resolution order (SafeResponses.for):
26
+ # agent[category] → agent["default"] → built-in[category] → built-in[:default].
27
+ class Config
28
+ # strictness -> input categories that the deterministic scan runs.
29
+ # low = only injection (+ output redaction) — highest confidence, fewest FPs
30
+ # medium = injection + sexual + abuse (the default)
31
+ # high = same families, reserved for future broader lists
32
+ STRICTNESS_CATEGORIES = {
33
+ low: %i[injection],
34
+ medium: %i[injection sexual abuse],
35
+ high: %i[injection sexual abuse]
36
+ }.freeze
37
+
38
+ DEFAULT_STRICTNESS = :medium
39
+
40
+ attr_reader :input, :output, :moderator, :strictness, :responses
41
+
42
+ def initialize(input:, output:, moderator:, strictness:, responses: {})
43
+ @input = input
44
+ @output = output
45
+ @moderator = moderator
46
+ @strictness = strictness
47
+ @responses = responses # { "category" => "safe reply" }, agent override map
48
+ end
49
+
50
+ # Builds a Config from a profile. A nil/empty `guardrails` -> the
51
+ # conservative default (see from_hash).
52
+ def self.from_profile(profile) = from_hash(profile.guardrails)
53
+
54
+ def self.from_hash(raw)
55
+ h = symbolize(raw)
56
+ new(
57
+ input: bool(h.fetch(:input, true)),
58
+ output: bool(h.fetch(:output, true)),
59
+ moderator: presence(h[:moderator]),
60
+ strictness: normalize_strictness(h[:strictness]),
61
+ responses: normalize_responses(h[:responses])
62
+ )
63
+ end
64
+
65
+ # Input categories the deterministic scan should run, per strictness.
66
+ def input_categories = STRICTNESS_CATEGORIES.fetch(@strictness, STRICTNESS_CATEGORIES[DEFAULT_STRICTNESS])
67
+
68
+ # A guardrail is fully off only when BOTH sides are disabled — cheap early-out.
69
+ def enabled? = @input || @output
70
+
71
+ def moderator? = !@moderator.nil?
72
+
73
+ def self.symbolize(raw)
74
+ return {} unless raw.is_a?(Hash)
75
+
76
+ raw.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
77
+ end
78
+
79
+ def self.bool(v)
80
+ return v if v == true || v == false
81
+ return false if v.nil?
82
+
83
+ s = v.to_s.strip.downcase
84
+ !s.empty? && !%w[false 0 off no].include?(s)
85
+ end
86
+
87
+ def self.presence(v) = Insika::Coercion.presence(v)
88
+
89
+ def self.normalize_strictness(v)
90
+ sym = v.to_s.strip.downcase.to_sym
91
+ STRICTNESS_CATEGORIES.key?(sym) ? sym : DEFAULT_STRICTNESS
92
+ end
93
+
94
+ # Per-agent safe-reply overrides -> { "category" => "text" } with STRING keys
95
+ # (SafeResponses looks up by string), blanks dropped. Tolerant of the JSON
96
+ # round-trip and of a non-Hash (ignored -> {}).
97
+ def self.normalize_responses(v)
98
+ return {} unless v.is_a?(Hash)
99
+
100
+ v.each_with_object({}) do |(k, val), acc|
101
+ text = val.to_s.strip
102
+ acc[k.to_s] = text unless text.empty?
103
+ end
104
+ end
105
+
106
+ private_class_method :symbolize, :bool, :presence, :normalize_strictness, :normalize_responses
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Safety
5
+ # SINGLE SOURCE of truth for content-safety pattern matching (RFC-0009 D4).
6
+ #
7
+ # Two families live here on purpose — the same lists back BOTH the runtime
8
+ # guardrail (§3.1/§3.2) AND the eval's `must_not` detectors (RFC-0008): the eval
9
+ # is a CLIENT of the runtime by design, so `evals/lib/evals/assertions.rb`
10
+ # requires THIS file rather than keeping a divergent copy. The runtime must never
11
+ # depend on `evals/`, so the file is deliberately self-contained (pure Ruby +
12
+ # frozen regexes, no other Insika require) — it loads standalone from either side.
13
+ #
14
+ # · OUTPUT side (PII/secret) — patterns that must never reach a customer turn.
15
+ # Consumed by the OutputFilter (stream redaction) and the eval `pii_leak`.
16
+ # · INPUT side (injection/abuse/sexual) — high-confidence heuristics that
17
+ # short-circuit the turn with a safe refusal BEFORE the LLM runs.
18
+ #
19
+ # Everything here is CONSERVATIVE by design (RFC §6: a false positive blocks a
20
+ # legitimate customer). The deterministic layer catches only the gross,
21
+ # unambiguous cases; the subtler judgment (social engineering, tone) is the LLM
22
+ # moderator's job (Fase C), not regex.
23
+ #
24
+ # LANGUAGE: the input heuristics are inherently language-specific. We ship pt-BR
25
+ # + EN (the pilot + the OSS lingua franca) as a BEST-EFFORT net; other languages
26
+ # rely on the LLM moderator, which is language-agnostic. Adding a language = adding
27
+ # patterns to the arrays below — it never needs core changes.
28
+ module Detectors
29
+ module_function
30
+
31
+ # ── OUTPUT: PII / secret (redaction targets) ────────────────────────────
32
+ # Formatted BR CPF/CNPJ only — a bare digit run (an order number, a price) is
33
+ # too ambiguous to flag. Credential shapes that must never leak.
34
+ PII = {
35
+ "cpf" => /\b\d{3}\.\d{3}\.\d{3}-\d{2}\b/,
36
+ "cnpj" => /\b\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}\b/,
37
+ "secret" => /\b(?:sk-[A-Za-z0-9]{16,}|Bearer\s+[A-Za-z0-9._-]{16,})\b/
38
+ }.freeze
39
+
40
+ # A run of the output stream that MIGHT still be growing into a PII/secret
41
+ # match if more chunks arrive — anchored at the buffer tail. The OutputFilter
42
+ # holds back from the start of such a run so a value split across chunk
43
+ # boundaries is never emitted in the clear (RFC §3.2 / D3). Covers the
44
+ # unbounded `sk-…`/`Bearer …` case that a fixed window cannot.
45
+ #
46
+ # Crucially it also matches a PARTIAL literal PREFIX at the tail — a lone "s"
47
+ # (start of "sk-"), "Bear" (start of "Bearer "), a trailing digit run — because
48
+ # the prefix ITSELF can be split across chunks (emitting the "s" then matching
49
+ # "k-…" alone would miss the secret entirely). The cost is a few chars of tail
50
+ # latency on words ending in "s"/"B"/a digit, released on the next chunk or flush.
51
+ OPEN_TAIL = %r{
52
+ (?:
53
+ s(?:k(?:-[A-Za-z0-9]*)?)? # prefix of "sk-" + optional body
54
+ | B(?:e(?:a(?:r(?:e(?:r(?:\s+[A-Za-z0-9._-]*)?)?)?)?)?)? # prefix of "Bearer " + body
55
+ | \d[\d./-]* # in-progress CPF/CNPJ digit run
56
+ )\z
57
+ }x
58
+
59
+ # ── INPUT: prompt-injection / exfiltration ──────────────────────────────
60
+ INJECTION = [
61
+ # exfil of the system prompt / internal rules
62
+ /\binstru[çc][õo]es\s+de\s+sistema\b/i,
63
+ /\bsystem\s*prompt\b/i,
64
+ /\b(regras|instru[çc][õo]es|orienta[çc][õo]es|diretrizes)\s+internas\b/i,
65
+ /\b(revele|mostre|exiba|me\s+(d[êe]|mande|envie|passe)|repita|imprima)\b[^.?!]{0,40}\b(prompt|instru[çc][õo]es|regras|configura[çc][ãa]o|system)\b/i,
66
+ # "ignore/disregard the (previous) instructions"
67
+ /\b(ignore|ignora|desconsidere|esque[çc]a)\b[^.?!]{0,30}\b(instru[çc][õo]es|regras|orienta[çc][õo]es|acima|anteriores)\b/i,
68
+ /\b(ignore|disregard|forget)\b[^.?!]{0,30}\b(instructions|rules|prompt|above|previous|prior)\b/i,
69
+ # encode/translate the prompt (the base64/rot13 exfil trick, either order)
70
+ /\b(base64|rot13|codific|encode|cifr)\w*\b[^.?!]{0,60}\b(instru[çc][õo]es|prompt|regras|sistema|system)\b/i,
71
+ /\b(instru[çc][õo]es|prompt|regras|sistema|system)\b[^.?!]{0,60}\b(base64|rot13|codific|encode|cifr)\w*\b/i
72
+ ].freeze
73
+
74
+ # ── INPUT: sexual / inappropriate ───────────────────────────────────────
75
+ # pt-BR + EN. Deterministic coverage is best-effort per language (see the
76
+ # module note): other languages fall to the LLM moderator (language-agnostic).
77
+ SEXUAL = [
78
+ /\b(nudes?|pelad[oa]s?|s?exo|transar|transa\b|gozar|tes[ãa]o|s[ãa]fad[oa]|puta|pau|buceta|piroca|caralho\s+(duro|na))\b/i,
79
+ /\bo\s+que\s+voc[êe]\s+faria\s+comigo\b/i,
80
+ /\b(descrev|imagina|conta)\w*\b[^.?!]{0,30}\bcomigo\s+(na\s+cama|pelad)/i,
81
+ /\b(quer|vamos)\b[^.?!]{0,20}\b(transar|fazer\s+sexo|sexo)\b/i,
82
+ # EN
83
+ /\b(horny|blow\s?job|hand\s?job|jerk\s+off|have\s+sex|send\s+(me\s+)?(a\s+)?nudes?|dick\s+pic)\b/i,
84
+ /\bwhat\s+(would|will)\s+you\s+do\s+to\s+me\b/i
85
+ ].freeze
86
+
87
+ # ── INPUT: verbal abuse / harassment (directed at the assistant) ─────────
88
+ # Directed insult only — "a entrega foi uma merda" (frustration about the
89
+ # service) must NOT block; "você é uma merda de atendente" (insult at the bot)
90
+ # should. The `você é …` anchor keeps precision high.
91
+ ABUSE = [
92
+ /\bvoc[êe]\s+(é|e|ta|est[áa])\b[^.?!]{0,25}\b(lixo|in[uú]til|merda|imprest[aá]vel|idiota|burr[oa]|est[uú]pid[oa]|otári[oa]|in[uú]teis|incompetente|p[áa]ssim[oa])\b/i,
93
+ /\b(seu|sua)\s+(lixo|in[uú]til|idiota|imbecil|otári[oa]|burr[oa]|est[uú]pid[oa]|merda|escrot[oa])\b/i,
94
+ /\bvai\s+(se\s+)?(fuder|foder|tomar\s+no)\b/i,
95
+ # EN — directed insult only (keeps precision high; frustration ≠ abuse)
96
+ /\byou\s*(?:'?re|\s+are)\b[^.?!]{0,25}\b(useless|garbage|trash|idiot|stupid|worthless|pathetic|incompetent|dumb|a\s+joke)\b/i,
97
+ /\b(fuck|screw)\s+you\b/i,
98
+ /\byou\s+(suck|are\s+the\s+worst)\b/i
99
+ ].freeze
100
+
101
+ # ── OUTPUT helpers ──────────────────────────────────────────────────────
102
+
103
+ # Runs a NAMED output detector over `text` -> the matched substring | nil.
104
+ # "pii_leak" = union of all PII patterns; otherwise a single named pattern.
105
+ # Unknown name fails LOUD (a typo'd assertion must never silently pass).
106
+ def detect(name, text)
107
+ patterns =
108
+ if name.to_s == "pii_leak"
109
+ PII.values
110
+ else
111
+ p = PII[name.to_s]
112
+ raise ArgumentError, "unknown detector: #{name.inspect}" unless p
113
+
114
+ [p]
115
+ end
116
+ patterns.each { |re| (m = text.to_s.match(re)) && (return m[0]) }
117
+ nil
118
+ end
119
+
120
+ # Names of the PII detectors (for iteration by the eval / config).
121
+ def pii_names = PII.keys
122
+
123
+ # [[begin, end), ...] byte-index ranges of every PII/secret match in `text`
124
+ # (used by the OutputFilter to avoid splitting a complete match at a chunk
125
+ # boundary).
126
+ def match_ranges(text)
127
+ ranges = []
128
+ PII.each_value do |re|
129
+ text.to_s.scan(re) { ranges << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] }
130
+ end
131
+ ranges
132
+ end
133
+
134
+ # Replaces every PII/secret occurrence with an opaque `[REDACTED:<name>]`
135
+ # marker — the raw value NEVER survives (D "o redigido nunca aparece em
136
+ # claro"). -> [redacted_text, {name => count}].
137
+ def redact(text)
138
+ counts = Hash.new(0)
139
+ out = text.to_s.dup
140
+ PII.each do |name, re|
141
+ out = out.gsub(re) do
142
+ counts[name] += 1
143
+ "[REDACTED:#{name}]"
144
+ end
145
+ end
146
+ [out, counts]
147
+ end
148
+
149
+ # ── INPUT helpers ───────────────────────────────────────────────────────
150
+
151
+ # Scans a user message against the input heuristics, gated by strictness
152
+ # (see Insika::Safety::Config). Returns { category:, matched: } for the FIRST
153
+ # category that fires (injection is checked first — the highest-stakes), or
154
+ # nil when the message is clean. `categories` limits which families run.
155
+ #
156
+ # :injection -> always high confidence
157
+ # :sexual -> medium+
158
+ # :abuse -> medium+
159
+ def scan_input(text, categories: %i[injection sexual abuse])
160
+ s = text.to_s
161
+ return { category: :injection, matched: first_match(INJECTION, s) } if categories.include?(:injection) && any?(INJECTION, s)
162
+ return { category: :sexual, matched: first_match(SEXUAL, s) } if categories.include?(:sexual) && any?(SEXUAL, s)
163
+ return { category: :abuse, matched: first_match(ABUSE, s) } if categories.include?(:abuse) && any?(ABUSE, s)
164
+
165
+ nil
166
+ end
167
+
168
+ def any?(patterns, text) = patterns.any? { |re| re.match?(text) }
169
+
170
+ def first_match(patterns, text)
171
+ patterns.each { |re| (m = text.match(re)) && (return m[0]) }
172
+ nil
173
+ end
174
+ end
175
+ end
176
+ end