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,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Source of AgentProfiles. Profiles used to be
5
+ # a FROZEN Hash injected into the Executor and the turn Commands — static,
6
+ # defined in Ruby at wiring time. For the Studio to create/edit agents at
7
+ # runtime, the source needs to be MUTABLE and reloadable, without changing the
8
+ # consumption contract.
9
+ #
10
+ # The consumption contract is minimal and duck-typed: `source[id] -> AgentProfile|nil`
11
+ # (like a Hash). That's why the refactor in the Executor/Commands is just
12
+ # normalizing the input (legacy Hash -> StaticProfileSource); the bodies that do
13
+ # `@profiles[agent]` stay identical. `all`/`ids` are for the Studio to list.
14
+ #
15
+ # `nil` from `[]`/`fetch` = agent not configured (the Commands raise
16
+ # NotFoundError) — NEVER raises (unlike Hash#fetch).
17
+ module ProfileSource
18
+ # Hash-compat sugar: `source[id]`.
19
+ def [](id) = fetch(id)
20
+
21
+ # Normalizes the consumers' input: a legacy Hash becomes a StaticProfileSource;
22
+ # a ProfileSource passes straight through. A single place for the compat seam.
23
+ def self.coerce(profiles)
24
+ return profiles if profiles.is_a?(ProfileSource)
25
+
26
+ StaticProfileSource.new(profiles || {})
27
+ end
28
+
29
+ # subclasses implement: fetch(id) -> AgentProfile|nil, all -> [AgentProfile], ids -> [String]
30
+ end
31
+
32
+ # Static source (parity): wraps the usual {id => AgentProfile} Hash.
33
+ # Behavior IDENTICAL to the frozen Hash — zero regression.
34
+ class StaticProfileSource
35
+ include ProfileSource
36
+
37
+ def initialize(profiles = {})
38
+ @profiles = profiles
39
+ end
40
+
41
+ def fetch(id) = @profiles[id]
42
+ def all = @profiles.values
43
+ def ids = @profiles.keys
44
+ end
45
+
46
+ # Persisted source (Studio): reads/writes profiles in the ConfigStore (scope "agents").
47
+ # Each `fetch` reads FRESH from the store — an edit via the Studio takes effect on
48
+ # the next dispatch, without a restart. An in-flight turn keeps the profile it
49
+ # captured (the Commands resolve at the start of #call), so the turn semantics are
50
+ # preserved.
51
+ class StoredProfileSource
52
+ include ProfileSource
53
+ include Coercion
54
+
55
+ SCOPE = "agents"
56
+
57
+ def initialize(config_store:)
58
+ @cs = config_store
59
+ end
60
+
61
+ def fetch(id)
62
+ record = @cs.get(SCOPE, id.to_s)
63
+ record && deserialize(record)
64
+ end
65
+
66
+ def all = @cs.all(SCOPE).map { |r| deserialize(r) }
67
+ def ids = @cs.keys(SCOPE)
68
+
69
+ # Write (used by the :create_agent/:update_agent Commands).
70
+ def put(profile)
71
+ @cs.put(SCOPE, profile.id, profile.to_h)
72
+ profile
73
+ end
74
+
75
+ def delete(id) = @cs.delete(SCOPE, id.to_s)
76
+
77
+ private
78
+
79
+ # Rebuilds the AgentProfile from the record (the JSON round-trip turns symbols
80
+ # into strings). Re-symbolizes the fields the runtime consumes as symbols:
81
+ # provider, policies (names in the PolicyRegistry) and the limits keys (
82
+ # DEFAULT_LIMITS uses symbols and the merge would break with string keys).
83
+ def deserialize(record)
84
+ h = symbolize_top(record)
85
+ AgentProfile.build(
86
+ id: h[:id], model: h[:model],
87
+ provider: presence(h[:provider])&.to_sym,
88
+ base_prompt: h[:base_prompt].to_s,
89
+ prompt_files: h[:prompt_files] || [],
90
+ tools_allow: h[:tools_allow], tools_deny: h[:tools_deny] || [],
91
+ tools_allow_groups: h[:tools_allow_groups],
92
+ skills: h[:skills],
93
+ context_providers: h[:context_providers],
94
+ workflows_allow: h[:workflows_allow],
95
+ policies: Array(h[:policies]).map(&:to_sym),
96
+ prompt_refs: h[:prompt_refs] || [],
97
+ limits: symbolize_limits(h[:limits]),
98
+ approvals_required: h[:approvals_required],
99
+ capabilities: h[:capabilities],
100
+ # subagents (RFC-0010): allowlist of child ids; build re-normalizes to
101
+ # [String]. nil round-trips as nil (opt-in: NONE).
102
+ subagents: h[:subagents],
103
+ tools_deferred: h[:tools_deferred],
104
+ memory: h[:memory],
105
+ prompt_caching: h[:prompt_caching],
106
+ # params/model_policy (v2, §10): the resolver tolerates string keys from
107
+ # the JSON round-trip (ModelResolver#normalize_params / ModelPolicy), so no
108
+ # re-symbolization needed here.
109
+ params: h[:params] || {},
110
+ model_policy: h[:model_policy],
111
+ # guardrails (RFC-0009): a plain Hash; Safety::Config tolerates the JSON
112
+ # round-trip (string keys/values), so no re-symbolization here.
113
+ guardrails: h[:guardrails],
114
+ # sandbox (item 35): a plain config Hash; Sandbox.build tolerates the JSON
115
+ # round-trip (string keys), so no re-symbolization here. nil = absent.
116
+ sandbox: h[:sandbox],
117
+ # refinement (RFC-0013): a plain config Hash read with string keys by the
118
+ # RunRefinement handler; nil round-trips as nil (= report-only).
119
+ refinement: h[:refinement],
120
+ # capabilities_declared (RFC-0014 §3.5): flat [String]; build re-normalizes.
121
+ capabilities_declared: h[:capabilities_declared],
122
+ # edge_stream: which internal channels may cross to the customer. {} = neither.
123
+ edge_stream: h[:edge_stream],
124
+ metadata: h[:metadata] || {}
125
+ )
126
+ end
127
+
128
+ def symbolize_top(record) = record.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
129
+
130
+ # limits keys -> symbol; numeric values preserved by JSON.
131
+ def symbolize_limits(limits)
132
+ return {} if limits.nil?
133
+
134
+ limits.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Insika
6
+ # Catalog of prompts: NON-executable content
7
+ # (it is a Catalog, not a Registry). Mirrors the SkillCatalog: each prompt is a directory
8
+ # with a PROMPT.md (YAML frontmatter name/description + markdown body).
9
+ # Source for the Prompt provider when the profile uses prompt_refs.
10
+ class PromptCatalog
11
+ Prompt = Data.define(:name, :description, :path, :body)
12
+
13
+ # roots ordered by PRECEDENCE (highest first) — same name in more than
14
+ # one root: the first wins (identical to SkillCatalog).
15
+ def initialize(roots)
16
+ @roots = Array(roots)
17
+ @prompts = load_all
18
+ end
19
+
20
+ def all = @prompts.values
21
+
22
+ # -> Prompt | nil (the Prompt provider converts nil into a ContextError; it is not
23
+ # the catalog's job to raise).
24
+ def find(name) = @prompts[name.to_s]
25
+
26
+ # Rescan + atomic index swap: parity with the SkillCatalog
27
+ # for reload without a restart when the on-disk seed changes.
28
+ def reload
29
+ @prompts = load_all
30
+ self
31
+ end
32
+
33
+ private
34
+
35
+ def load_all
36
+ found = {}
37
+ @roots.each do |root|
38
+ Dir.glob(File.join(root, "**", "PROMPT.md")).sort.each do |file|
39
+ prompt = parse(file)
40
+ next unless prompt
41
+
42
+ found[prompt.name] ||= prompt # precedence: first root wins
43
+ end
44
+ end
45
+ found
46
+ end
47
+
48
+ def parse(file)
49
+ raw = File.read(file, encoding: "UTF-8")
50
+ match = raw.match(/\A---\s*\n(.*?)\n---\s*\n(.*)\z/m)
51
+ return nil unless match
52
+
53
+ meta = YAML.safe_load(match[1]) || {}
54
+ name = meta["name"]
55
+ return nil unless name
56
+
57
+ Prompt.new(name: name.to_s, description: meta["description"].to_s,
58
+ path: file, body: match[2].strip)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "coercion"
4
+
5
+ module Insika
6
+ # RFC-0015 §4 — what happens to an inbound message for a session that is ALREADY
7
+ # busy. Today the engine has exactly one answer, "it waits in line"; this names
8
+ # that answer `followup` and adds three others:
9
+ #
10
+ # collect — the message arrived BEFORE the turn started: merge the fragments
11
+ # into one turn (the whole mechanism is a timer at the door).
12
+ # steer — the turn is ALREADY running tools: append the message to the run in
13
+ # flight, at a tool-batch boundary, so the customer's correction lands
14
+ # before the model's next step instead of after the whole run.
15
+ # interrupt — the turn is running and is now answering the wrong question: abandon
16
+ # it at its next boundary and let the new message be its own turn.
17
+ #
18
+ # They never compete for the same message: `collect` only ever touches a turn that
19
+ # has not started; `steer` and `interrupt` only a turn that has, and they differ in
20
+ # whether the run in flight is still worth finishing.
21
+ #
22
+ # Resolution per message, the order EdgeLimiter already documents
23
+ # (`edge_limiter.rb:17`) — configuration over convention:
24
+ #
25
+ # session vars["queue_mode"] — one conversation pinned by an operator
26
+ # profile.limits[:<key>] — per-agent (a PRESENT key wins, incl. nil/0 = off)
27
+ # settings["queue"][<key>] — platform default, editable in the Studio
28
+ # DEFAULTS[<key>] — = today's behavior
29
+ #
30
+ # Every default is off: a bare wiring behaves exactly as it did before this
31
+ # existed. The knobs live in `profile.limits` next to `tool_concurrency` and
32
+ # `turn_timeout` because they are bounds on the same thing — how much work one
33
+ # turn is allowed to absorb.
34
+ class QueuePolicy
35
+ # All four of RFC-0015 are delivered, so there is no "specified but unshipped"
36
+ # tier any more — a mode outside this set is a typo, and it is refused rather than
37
+ # approximated. Treating an unknown mode as `followup` would look exactly like a
38
+ # mode that never fires.
39
+ MODES = %i[followup collect steer interrupt].freeze
40
+
41
+ DEFAULTS = {
42
+ queue_mode: :followup,
43
+ debounce_ms: 0, # 0 = no window: dequeue immediately, today's path
44
+ debounce_max_ms: 10_000, # ceiling on the TOTAL deferral (see #debounce_deadline_ms)
45
+ steer_max_messages: 5, # how many messages ONE run may absorb; overflow = followup
46
+ steer_join: nil # nil = the raw text; a template frames it (see #frame)
47
+ }.freeze
48
+
49
+ # The placeholder `steer_join` must carry, so a template that would silently
50
+ # drop the customer's message is a config error and not a lost message.
51
+ JOIN_PLACEHOLDER = "%{message}"
52
+
53
+ attr_reader :mode, :debounce_ms, :debounce_max_ms, :steer_max_messages, :steer_join
54
+
55
+ # profile: an AgentProfile (or nil); settings_store: nil = no platform layer;
56
+ # vars: the session's vars Hash (string keys, as the SessionStore returns).
57
+ def self.resolve(profile, settings_store: nil, vars: nil)
58
+ platform = ((settings_store&.get || {})["queue"] || {})
59
+ limits = profile.respond_to?(:limits) ? (profile.limits || {}) : {}
60
+
61
+ new(
62
+ mode: mode!(pick_mode(vars, limits, platform)),
63
+ debounce_ms: pick(:debounce_ms, limits, platform),
64
+ debounce_max_ms: pick(:debounce_max_ms, limits, platform),
65
+ steer_max_messages: pick(:steer_max_messages, limits, platform),
66
+ steer_join: pick_text(:steer_join, limits, platform)
67
+ )
68
+ end
69
+
70
+ def initialize(mode:, debounce_ms:, debounce_max_ms:,
71
+ steer_max_messages: DEFAULTS[:steer_max_messages], steer_join: nil)
72
+ @mode = mode
73
+ @debounce_ms = [debounce_ms.to_i, 0].max
74
+ # A non-positive ceiling would mean "defer forever", which nobody wants and
75
+ # which a stray 0 in a config would silently buy. Fall back to the default.
76
+ max = debounce_max_ms.to_i
77
+ @debounce_max_ms = max.positive? ? max : DEFAULTS[:debounce_max_ms]
78
+ # 0 (or a negative) is a legitimate "never steer on this agent" — unlike the
79
+ # ceiling above, it forbids rather than defers forever, so it is honored.
80
+ @steer_max_messages = [steer_max_messages.to_i, 0].max
81
+ @steer_join = join!(steer_join)
82
+ end
83
+
84
+ # Does this policy want messages held at the door at all?
85
+ def debounce? = @debounce_ms.positive?
86
+
87
+ # Does this policy merge into a turn that has not started yet?
88
+ def collect? = @mode == :collect
89
+
90
+ # Does this policy append into a turn that is already running? `steer_max_messages`
91
+ # of 0 is the agent saying no, so it answers false rather than steering once and
92
+ # then refusing.
93
+ def steer? = @mode == :steer && @steer_max_messages.positive?
94
+
95
+ # Does this policy abandon the turn in flight? The new message then becomes an
96
+ # ordinary turn of its own — which is why `interrupt`, unlike the two joining modes,
97
+ # needs no verdict field and works on every surface.
98
+ def interrupt? = @mode == :interrupt
99
+
100
+ # The content of the injected message. `steer_join` frames it when an agent needs
101
+ # the model to know this text arrived mid-run ("the customer just added: %{message}");
102
+ # nil — the default — appends exactly what the person typed. A plain gsub, not
103
+ # `format`: the text is a customer's, and a stray `%` in it must not raise.
104
+ def frame(text)
105
+ return text.to_s if @steer_join.nil?
106
+
107
+ @steer_join.gsub(JOIN_PLACEHOLDER, text.to_s)
108
+ end
109
+
110
+ # A mode name -> Symbol, or raise. Blank = the default (an absent key is not
111
+ # an error; a WRONG key is).
112
+ def self.mode!(value)
113
+ return DEFAULTS[:queue_mode] if Coercion.blank?(value)
114
+
115
+ name = value.to_s.strip.downcase.to_sym
116
+ return name if MODES.include?(name)
117
+
118
+ raise Insika::ValidationError,
119
+ "unknown queue_mode: #{value.inspect} (expected #{MODES.join(', ')})"
120
+ end
121
+
122
+ # A present key WINS even carrying nil/0 — "off for this agent", never
123
+ # "inherit the platform default". Same semantics EdgeLimiter documents at
124
+ # `edge_limiter.rb:57`, and for the same reason: an imported pack carrying an
125
+ # explicit null must not silently re-enable a platform behavior.
126
+ def self.pick(key, limits, platform)
127
+ return limits[key].to_i if limits.key?(key)
128
+ return platform[key.to_s].to_i if platform.key?(key.to_s)
129
+
130
+ DEFAULTS[key]
131
+ end
132
+
133
+ # Same rule as #pick for a key whose value is TEXT: `to_i` would turn a template
134
+ # into 0. A present-but-nil key still means "off for this agent".
135
+ def self.pick_text(key, limits, platform)
136
+ return limits[key] if limits.key?(key)
137
+ return platform[key.to_s] if platform.key?(key.to_s)
138
+
139
+ DEFAULTS[key]
140
+ end
141
+
142
+ def self.pick_mode(vars, limits, platform)
143
+ from_vars = (vars || {})["queue_mode"] || (vars || {})[:queue_mode]
144
+ return from_vars if Coercion.present?(from_vars)
145
+ return limits[:queue_mode] if limits.key?(:queue_mode)
146
+
147
+ platform["queue_mode"]
148
+ end
149
+
150
+ private_class_method :pick, :pick_text, :pick_mode
151
+
152
+ private
153
+
154
+ # A template that does not carry the placeholder would drop the customer's message
155
+ # while the turn still looked steered. Refused where the operator is (config time),
156
+ # not at 14:02 on a live conversation.
157
+ def join!(value)
158
+ return nil if Coercion.blank?(value)
159
+
160
+ text = value.to_s
161
+ return text if text.include?(JOIN_PLACEHOLDER)
162
+
163
+ raise Insika::ValidationError,
164
+ "steer_join must contain #{JOIN_PLACEHOLDER} (got #{value.inspect})"
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # Called ONCE at boot, BEFORE accepting requests. Discovers
8
+ # interrupted tasks and dispatches the resume through the SAME path as
9
+ # ResumeTask — this component executes nothing, opens no Execution, changes no
10
+ # status of resumable tasks. Just discovery + dispatch + marking of
11
+ # unrecoverable tasks.
12
+ #
13
+ # Durability without an external job runner = stores +
14
+ # recovery at boot. The "execution" half is the ResumeTask handler.
15
+ #
16
+ # `command_bus` is consumed only through the `dispatch(command)` contract.
17
+ class Recovery
18
+ SWEEP_SCOPE = "recovery"
19
+
20
+ # The per-boot-generation sweep claim (RFC-0016 E2). N workers share one
21
+ # store, and the sweep's "orphaned :running" test is per-process: a worker
22
+ # booting while a sibling holds a live turn would see it as an orphan and
23
+ # re-run it. So the TASK sweep runs once per boot generation — the first
24
+ # worker to claim `boot_id` sweeps, the rest skip; a worker respawned
25
+ # mid-generation skips too (its own orphans wait for the next generation).
26
+ # Rides Store#transaction like every claim. nil/empty boot_id (single
27
+ # process: DSL serve, scripts, tests) -> always true, every boot sweeps.
28
+ def self.claim_sweep(store:, boot_id:)
29
+ id = boot_id.to_s
30
+ return true if id.empty?
31
+
32
+ store.transaction do
33
+ key = "sweep:#{id}"
34
+ if store.get(SWEEP_SCOPE, key).nil?
35
+ store.set(SWEEP_SCOPE, key, { "claimed_at" => Time.now.utc.iso8601 })
36
+ true
37
+ else
38
+ false
39
+ end
40
+ end
41
+ end
42
+
43
+ # checkpoint_store: needed to query `latest`. logger optional
44
+ # (default nil -> silent in tests).
45
+ def initialize(task_store:, checkpoint_store:, command_bus:, logger: nil)
46
+ @task_store = task_store
47
+ @checkpoint_store = checkpoint_store
48
+ @command_bus = command_bus
49
+ @logger = logger
50
+ end
51
+
52
+ # -> { resumed: [ids], failed: [ids] }
53
+ # The initial sweep runs OUTSIDE the per-task rescue: a StoreError here
54
+ # aborts the boot.
55
+ def run
56
+ resumed = []
57
+ failed = []
58
+ # Ordered by created_at: tasks from the SAME session are reprocessed
59
+ # in their original order. Global time ordering is harmless for standalone tasks.
60
+ # 1) interrupted (crash mid-flight) -> resume from the checkpoint.
61
+ @task_store.running_or_interrupted.sort_by(&:created_at).each { |task| process(task, resumed, failed) }
62
+ # 2) queued but never started (turn in the SessionActor queue at crash time)
63
+ # -> re-run from scratch (the same resume_task handles :queued). Without
64
+ # this, a :queued turn in the volatile queue would be lost on kill -9.
65
+ @task_store.queued.sort_by(&:created_at).each { |task| process(task, resumed, failed) }
66
+ log(:info, "recovery finished: #{resumed.size} resumed, #{failed.size} failed")
67
+ { resumed: resumed, failed: failed }
68
+ end
69
+
70
+ private
71
+
72
+ # Failing to resume ONE task does not bring down the boot: a non-store
73
+ # dispatch/latest error -> mark :failed and continue. StoreError -> propagate
74
+ # (aborts the boot).
75
+ def process(task, resumed, failed)
76
+ # :queued never started (no checkpoint) but IS recoverable — ResumeTask
77
+ # re-runs from the Command. An interrupted task requires a checkpoint;
78
+ # without one, it is unrecoverable.
79
+ if task.status == :queued || @checkpoint_store.latest(task.id)
80
+ @command_bus.dispatch(resume_command(task.id))
81
+ resumed << task.id
82
+ log(:info, "resume dispatched: #{task.id}")
83
+ else
84
+ fail_task(task.id, class_name: "Insika::Error",
85
+ message: "unrecoverable: no checkpoint")
86
+ failed << task.id
87
+ log(:warn, "unrecoverable (no checkpoint): #{task.id}")
88
+ end
89
+ rescue Insika::StoreError
90
+ raise
91
+ rescue StandardError => e
92
+ fail_task(task.id, class_name: e.class.name, message: e.message, stage: "recovery")
93
+ failed << task.id unless failed.include?(task.id)
94
+ log(:warn, "failed to resume #{task.id}: #{e.class}: #{e.message}")
95
+ end
96
+
97
+ # Transitions the task to :failed, writing the error into the open Execution
98
+ # (if any). StoreError propagates (aborts the boot). ArgumentError is
99
+ # absorbed: `paused -> failed` is not in the machine — the task stays in its
100
+ # current state, but we still report it as failed in the summary. Do not
101
+ # "fix" the machine here.
102
+ def fail_task(id, class_name:, message:, stage: nil)
103
+ error = { class: class_name, message: message }
104
+ error[:stage] = stage if stage
105
+ @task_store.transition(id, to: :failed, error: error)
106
+ rescue Insika::StoreError
107
+ raise
108
+ rescue ArgumentError
109
+ nil
110
+ end
111
+
112
+ def resume_command(task_id)
113
+ # transport: :recovery identifies the origin (boot) in meta — auditing
114
+ # (the field is a free Symbol).
115
+ Insika::Command.build(:resume_task, { task_id: task_id }, transport: :recovery)
116
+ end
117
+
118
+ # Logging is pure observability: a logger failure must NEVER alter the
119
+ # recovery flow (otherwise a buggy logger would corrupt the summary —
120
+ # e.g. an id in resumed AND failed). We swallow any logger error.
121
+ def log(level, message)
122
+ @logger&.public_send(level, "[recovery] #{message}")
123
+ rescue StandardError
124
+ nil
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Insika
6
+ module Refinement
7
+ # ONE proposed change to an agent's instruction files (RFC-0013 §3.4). Data, not
8
+ # a diff of free text, and that is the load-bearing decision of the whole phase:
9
+ #
10
+ # · an ANCHORED edit is reviewable — the operator reads three lines, not a
11
+ # rewritten file, and can tell in seconds whether to approve it;
12
+ # · it is ATTRIBUTABLE — when the gate's score moves, it moved because of an
13
+ # edit you can point at, not because a model rewrote the prompt;
14
+ # · it is STALE-CHECKABLE — `before` must still match the file, so a proposal
15
+ # built from a snapshot cannot silently clobber an edit made since.
16
+ #
17
+ # A candidate arrives from anywhere (a model, the Studio, a JSON payload) and is
18
+ # always built through `Candidate.build`, which is the only place the bounds and
19
+ # the write allowlist are enforced. An edit that violates one is DROPPED with a
20
+ # reason rather than failing the whole candidate: a proposal with two good edits
21
+ # and one stale one is worth gating, and the operator should see what fell off.
22
+ Edit = Data.define(:file, :op, :anchor, :before, :after, :addresses) do
23
+ def replace? = op == "replace"
24
+
25
+ # -> the new content, or nil when this edit no longer applies to `content`.
26
+ # `append` puts the text at the END of the file: `anchor` is a LABEL for the
27
+ # reviewer ("## shipping_quote"), never a locator — the locator is `before`,
28
+ # and inventing a second one would give the same edit two ways to land.
29
+ def apply_to(content)
30
+ return nil if content.nil?
31
+ return "#{content.chomp}\n\n#{after}\n" unless replace?
32
+ return nil unless content.include?(before)
33
+
34
+ content.sub(before, after)
35
+ end
36
+
37
+ def to_h = { "file" => file, "op" => op, "anchor" => anchor, "before" => before,
38
+ "after" => after, "addresses" => addresses }
39
+ end
40
+
41
+ # A dropped edit and why. Kept ON the candidate rather than logged: "the model
42
+ # proposed four things and one was stale" is exactly what an operator reviewing
43
+ # the loop's usefulness needs, and §10 asks them to judge precisely that.
44
+ Dropped = Data.define(:file, :op, :reason) do
45
+ def to_h = { "file" => file, "op" => op, "reason" => reason }
46
+ end
47
+
48
+ Candidate = Data.define(:id, :proposer, :rationale, :edits, :dropped) do
49
+ def empty? = edits.empty?
50
+ def files = edits.map(&:file).uniq
51
+
52
+ # name => new content, for the files this candidate touches. Edits to the same
53
+ # file compose in order, so two edits to TOOLS.md both land.
54
+ def apply(contents)
55
+ edits.each_with_object({}) do |edit, acc|
56
+ current = acc[edit.file] || contents[edit.file]
57
+ applied = edit.apply_to(current)
58
+ acc[edit.file] = applied unless applied.nil?
59
+ end
60
+ end
61
+
62
+ def to_h = { "id" => id, "proposer" => proposer, "rationale" => rationale,
63
+ "edits" => edits.map(&:to_h), "dropped" => dropped.map(&:to_h) }
64
+ end
65
+
66
+ # The bounds, all config (§3.4). Defaults are deliberately small: what makes a
67
+ # diff reviewable is that it is short, and what keeps the gate's signal readable
68
+ # is that a run changed few things.
69
+ DEFAULT_LIMITS = { "max_edits" => 3, "max_bytes" => 1200, "max_total_growth" => 0.15 }.freeze
70
+
71
+ OPS = %w[replace append].freeze
72
+
73
+ module CandidateBuilder
74
+ module_function
75
+
76
+ # raw: { "proposer" =>, "rationale" =>, "edits" => [ … ] } (string keys)
77
+ # allowlist: the agent's `refinement.files`. EMPTY MEANS NOTHING IS WRITABLE —
78
+ # report-only (§3.1/§3.8), so every edit drops. Not "no restriction":
79
+ # an unset allowlist that meant "anything" would turn a missing
80
+ # config into the most permissive setting there is.
81
+ # contents: name => current content, for staleness and growth.
82
+ # -> Candidate (possibly empty; `empty?` never reaches the gate).
83
+ def build(raw, allowlist:, contents:, limits: {}, id: nil)
84
+ raw = Coercion.deep_stringify(raw.is_a?(Hash) ? raw : {})
85
+ bounds = DEFAULT_LIMITS.merge(limits.is_a?(Hash) ? Coercion.deep_stringify(limits) : {})
86
+ allow = Array(allowlist).map(&:to_s)
87
+
88
+ kept = []
89
+ dropped = []
90
+ # Growth is measured per FILE across the whole candidate, so three edits that
91
+ # are each under the cap cannot add up to a rewritten file.
92
+ grown = Hash.new(0)
93
+
94
+ Array(raw["edits"]).each do |edit|
95
+ built, reason = validate(Coercion.deep_stringify(edit), allow, contents, bounds, grown, kept.size)
96
+ if built
97
+ kept << built
98
+ grown[built.file] += built.after.to_s.bytesize - (built.replace? ? built.before.to_s.bytesize : 0)
99
+ else
100
+ dropped << Dropped.new(file: edit_field(edit, "file"), op: edit_field(edit, "op"), reason: reason)
101
+ end
102
+ end
103
+
104
+ Candidate.new(id: (id || SecureRandom.uuid).to_s,
105
+ proposer: Coercion.presence(raw["proposer"]) || "operator",
106
+ rationale: raw["rationale"].to_s, edits: kept, dropped: dropped)
107
+ end
108
+
109
+ # -> [Edit, nil] | [nil, reason]. One reason per edit, in the order an operator
110
+ # would ask: is it allowed, is it well-formed, does it still apply, is it small.
111
+ def validate(raw, allow, contents, bounds, grown, kept_count)
112
+ file = raw["file"].to_s
113
+ op = raw["op"].to_s
114
+ after = raw["after"].to_s
115
+
116
+ return [nil, "candidate is over max_edits (#{bounds['max_edits']})"] if kept_count >= bounds["max_edits"].to_i
117
+ return [nil, "'#{file}' is not on the refinement allowlist"] unless allow.include?(file)
118
+ return [nil, "unknown op '#{op}' (#{OPS.join('|')})"] unless OPS.include?(op)
119
+
120
+ current = contents[file]
121
+ return [nil, "'#{file}' does not exist for this agent"] if current.nil?
122
+ return [nil, "'after' is empty"] if after.strip.empty?
123
+
124
+ if after.bytesize > bounds["max_bytes"].to_i
125
+ return [nil, "edit is #{after.bytesize}B, over max_bytes (#{bounds['max_bytes']})"]
126
+ end
127
+
128
+ edit = Edit.new(file: file, op: op, anchor: Coercion.presence(raw["anchor"]),
129
+ before: raw["before"].to_s, after: after,
130
+ addresses: Array(raw["addresses"]).map(&:to_s))
131
+
132
+ if edit.replace?
133
+ return [nil, "'before' is empty — a replace with no anchor text is a rewrite"] if edit.before.empty?
134
+ # STALE: the file changed since the proposal was built (or the model
135
+ # hallucinated the text it claims to be replacing). Either way the edit
136
+ # describes a file that does not exist, and applying it by fuzzy match is
137
+ # how a refinement loop silently clobbers a human's edit.
138
+ return [nil, "'before' no longer matches '#{file}' (stale or invented)"] unless current.include?(edit.before)
139
+
140
+ if current.scan(edit.before).length > 1
141
+ return [nil, "'before' matches '#{file}' in #{current.scan(edit.before).length} places — ambiguous"]
142
+ end
143
+ end
144
+
145
+ growth = grown[file] + after.bytesize - (edit.replace? ? edit.before.bytesize : 0)
146
+ cap = (current.bytesize * bounds["max_total_growth"].to_f).ceil
147
+ return [nil, "would grow '#{file}' by #{growth}B, over max_total_growth (#{cap}B)"] if growth > cap
148
+
149
+ [edit, nil]
150
+ end
151
+
152
+ def edit_field(edit, key)
153
+ return nil unless edit.is_a?(Hash)
154
+
155
+ (edit[key] || edit[key.to_sym]).to_s
156
+ end
157
+ end
158
+ end
159
+ end