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,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ module Providers
6
+ # Reads the Session Store. The ONLY history provider: the three transcript
7
+ # sources converge here — the Executor does not pick a source. Produces
8
+ # :history fragments (1 per message), with priority scaled by recency
9
+ # with a CEILING of 79: the budget cut discards the oldest ones first
10
+ # and history NEVER outranks skills (80) or identity (100).
11
+ class Session < ContextProvider
12
+ def initialize(session_store:)
13
+ @session_store = session_store
14
+ end
15
+
16
+ def call(request)
17
+ messages = transcript_for(request)
18
+ return [] if messages.nil? || messages.empty?
19
+
20
+ # 1 fragment per EVICTION UNIT (§11 R1): a plain message, OR an
21
+ # assistant-with-tool_calls together with its tool results. Grouping at
22
+ # the fragment level means the budget cut (apply_budget) drops a whole
23
+ # tool cycle atomically — a tool_use is NEVER seeded without its result
24
+ # (which providers reject), without touching apply_budget itself.
25
+ eviction_units(messages).each_with_index.map do |unit, idx|
26
+ ContextFragment.build(
27
+ # single message stays a Hash (compat with existing fragments); a
28
+ # multi-message cycle is an Array (seed_history flattens it back).
29
+ content: unit.size == 1 ? unit.first : unit,
30
+ placement: :history,
31
+ # HISTORY_MAX ceiling; idx 0 = oldest (drops first in the cut)
32
+ priority: [Context::Priority::HISTORY_BASE + idx, Context::Priority::HISTORY_MAX].min,
33
+ source: id
34
+ )
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ # Groups a flat message list into eviction units. An assistant message
41
+ # carrying tool_calls absorbs the `role: tool` messages that immediately
42
+ # follow it (its results). Everything else is a unit of one.
43
+ def eviction_units(messages)
44
+ normalized = messages.map { |m| normalize(m) }
45
+ units = []
46
+ i = 0
47
+ while i < normalized.length
48
+ msg = normalized[i]
49
+ i += 1
50
+ if msg[:role].to_s == "assistant" && tool_calls?(msg)
51
+ cycle = [msg]
52
+ while i < normalized.length && normalized[i][:role].to_s == "tool"
53
+ cycle << normalized[i]
54
+ i += 1
55
+ end
56
+ units << cycle
57
+ else
58
+ units << [msg]
59
+ end
60
+ end
61
+ units
62
+ end
63
+
64
+ # Symbol-keyed message preserving tool_calls / tool_call_id when present
65
+ # (a plain message keeps just {role, content} — parity with the old shape).
66
+ def normalize(msg)
67
+ h = { role: msg[:role] || msg["role"], content: msg[:content] || msg["content"] }
68
+ tool_calls = msg[:tool_calls] || msg["tool_calls"]
69
+ tool_call_id = msg[:tool_call_id] || msg["tool_call_id"]
70
+ h[:tool_calls] = tool_calls if tool_calls
71
+ h[:tool_call_id] = tool_call_id if tool_call_id
72
+ h
73
+ end
74
+
75
+ def tool_calls?(msg) = msg[:tool_calls] && !Array(msg[:tool_calls]).empty?
76
+
77
+ # Precedence: checkpoint -> explicit history -> store.
78
+ # The first present source wins; no merge.
79
+ def transcript_for(request)
80
+ return request.checkpoint.messages if request.checkpoint
81
+
82
+ explicit = explicit_history(request)
83
+ return explicit if explicit
84
+ return session_messages(request.session) if request.session
85
+
86
+ nil
87
+ end
88
+
89
+ # Source 2 (explicit history): the handler passes it in request.vars[:history].
90
+ # Isolated in a single method to change the convention with 1 line.
91
+ def explicit_history(request)
92
+ vars = request.vars.to_h
93
+ vars[:history] || vars["history"]
94
+ end
95
+
96
+ # CONDITIONAL requiredness: when a session is requested, a read
97
+ # failure becomes a ContextError (aborts the turn); the base required?
98
+ # does not receive the request, so the behavior lives here.
99
+ def session_messages(session)
100
+ @session_store.find(session.id)&.messages || []
101
+ rescue StandardError => e # read failure (exception/StoreError)
102
+ raise ContextError.new("Session provider failed with a requested session: #{e.message}",
103
+ provider: id)
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ module Providers
6
+ # Level 1 (progressive disclosure) of the profile's CANDIDATE skills.
7
+ # The Executor's LoadSkill does NOT come from here: it is built with
8
+ # resolution.allowed_skills (a policy decision). Context->Policy order: the
9
+ # provider produces the candidate; the policy cuts afterwards.
10
+ class Skill < CatalogProvider
11
+ # priority 80: above deferred tools (70), below pinned identity.
12
+ def priority = Context::Priority::SKILL
13
+
14
+ private
15
+
16
+ def entries(request) = @catalog.effective(request.profile.skills)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ module Providers
6
+ # Level 1 (progressive disclosure) of the profile's deferred TOOLS. The
7
+ # slice is `profile.tools_deferred`, known at stage 2 — it may be slightly
8
+ # over-inclusive (showing a deferred tool the Policy later denies); acceptable,
9
+ # the real cut happens at PROMOTION (tool_search, `deferred ∩ allowed`).
10
+ class ToolSearch < CatalogProvider
11
+ # priority 70: below skills (80) in the sacrifice order.
12
+ def priority = Context::Priority::TOOL_SEARCH
13
+
14
+ private
15
+
16
+ def entries(request) = @catalog.subset(request.profile.tools_deferred)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # Durable record of an ASYNC delegation (RFC-0010 §5, item 21 Phase 2, hermes
8
+ # "delegation durability"). The synchronous subagent (Phase 1) needs no
9
+ # record — it lives and dies inside the parent's turn. The ASYNC subagent does:
10
+ # the parent DISPATCHES and its turn ends; the child runs independently; when the
11
+ # child finishes, its result is delivered to the parent as a NEW turn (never
12
+ # spliced mid-turn — preserves role alternation + prompt cache). This record is
13
+ # what survives a kill -9 so a completed child's result is never lost.
14
+ #
15
+ # Lifecycle (never backwards): dispatched -> completed -> delivered.
16
+ # dispatched the child was spawned; result not captured yet.
17
+ # completed the child is terminal; result/error captured durably.
18
+ # delivered the result was handed to the parent session (claimed — the
19
+ # claim is what makes delivery AT-MOST-ONCE across crashes).
20
+ #
21
+ # Normalizes symbol->string on WRITE (the backend only round-trips JSON types),
22
+ # like the other domain stores.
23
+ class DelegationStore
24
+ include Coercion
25
+
26
+ SCOPE = "delegations"
27
+ KEY_PREFIX = "delegation:"
28
+
29
+ STATUSES = %i[dispatched completed delivered].freeze
30
+
31
+ Delegation = Data.define(
32
+ :id, :parent_task_id, :parent_session_id, :parent_agent,
33
+ :child_agent, :child_task_id, :child_session_id, :depth,
34
+ :status, :result, :error, :created_at, :updated_at
35
+ )
36
+
37
+ def initialize(store:)
38
+ @store = store
39
+ end
40
+
41
+ # -> Delegation (:dispatched).
42
+ def create(parent_task_id:, parent_session_id:, parent_agent:, child_agent:,
43
+ child_task_id:, child_session_id:, depth:, id: SecureRandom.uuid)
44
+ record = {
45
+ "id" => id.to_s,
46
+ "parent_task_id" => parent_task_id.to_s,
47
+ "parent_session_id" => parent_session_id&.to_s,
48
+ "parent_agent" => parent_agent.to_s,
49
+ "child_agent" => child_agent.to_s,
50
+ "child_task_id" => child_task_id.to_s,
51
+ "child_session_id" => child_session_id.to_s,
52
+ "depth" => depth,
53
+ "status" => "dispatched",
54
+ "result" => nil,
55
+ "error" => nil,
56
+ "created_at" => timestamp,
57
+ "updated_at" => timestamp
58
+ }
59
+ @store.set(SCOPE, key_for(id), record)
60
+ to_delegation(record)
61
+ end
62
+
63
+ # -> Delegation | nil
64
+ def find(id)
65
+ record = @store.get(SCOPE, key_for(id))
66
+ record && to_delegation(record)
67
+ end
68
+
69
+ # -> Delegation | nil for a given child task (the terminal hook's lookup). O(n)
70
+ # scan — single-node, like PendingActionStore#open_for.
71
+ def find_by_child_task(child_task_id)
72
+ id = child_task_id.to_s
73
+ scan { |d| return d if d.child_task_id == id }
74
+ nil
75
+ end
76
+
77
+ # -> [Delegation] that are NOT delivered yet (boot recovery). completed-but-
78
+ # undelivered = a crash between capture and delivery; dispatched = the child
79
+ # may or may not be terminal (the caller checks the child task).
80
+ def undelivered
81
+ scan.reject { |d| d.status == :delivered }
82
+ end
83
+
84
+ # dispatched -> completed, capturing the child's result/error. Idempotent: a
85
+ # second call on an already-completed/delivered record is a no-op (returns the
86
+ # current record) — the terminal hook and recovery can race.
87
+ def mark_completed(id, result: nil, error: nil)
88
+ record = fetch!(id)
89
+ return to_delegation(record) unless record["status"] == "dispatched"
90
+
91
+ record["status"] = "completed"
92
+ record["result"] = result
93
+ record["error"] = error
94
+ touch(id, record)
95
+ end
96
+
97
+ # completed -> delivered, ATOMICALLY (the claim) — across processes, not just
98
+ # fibers: the read-check-write rides Store#transaction, so two workers racing
99
+ # the same record serialize on the backend's lock and only one sees
100
+ # :completed. Returns true only for the caller that won the transition — that
101
+ # caller (and only it) spawns the delivery turn, so delivery is at-most-once
102
+ # even if the hook and recovery both fire. A record not in :completed
103
+ # (already delivered, or still dispatched) -> false.
104
+ def claim_delivery(id)
105
+ @store.transaction do
106
+ record = @store.get(SCOPE, key_for(id))
107
+ next false unless record && record["status"] == "completed"
108
+
109
+ record["status"] = "delivered"
110
+ record["updated_at"] = timestamp
111
+ @store.set(SCOPE, key_for(id), record)
112
+ true
113
+ end
114
+ end
115
+
116
+ private
117
+
118
+ def scan
119
+ return enum_for(:scan) unless block_given?
120
+
121
+ @store.list(SCOPE, KEY_PREFIX).each do |key|
122
+ record = @store.get(SCOPE, key)
123
+ yield to_delegation(record) if record
124
+ end
125
+ end
126
+
127
+ def fetch!(id)
128
+ @store.get(SCOPE, key_for(id)) ||
129
+ (raise Insika::NotFoundError, "delegation not found: #{id}")
130
+ end
131
+
132
+ def touch(id, record)
133
+ record["updated_at"] = timestamp
134
+ @store.set(SCOPE, key_for(id), record)
135
+ to_delegation(record)
136
+ end
137
+
138
+ def key_for(id) = "#{KEY_PREFIX}#{id}"
139
+
140
+ def to_delegation(record)
141
+ Delegation.new(
142
+ id: record["id"], parent_task_id: record["parent_task_id"],
143
+ parent_session_id: record["parent_session_id"], parent_agent: record["parent_agent"],
144
+ child_agent: record["child_agent"], child_task_id: record["child_task_id"],
145
+ child_session_id: record["child_session_id"], depth: record["depth"],
146
+ status: record["status"].to_sym, result: record["result"], error: record["error"],
147
+ created_at: record["created_at"], updated_at: record["updated_at"]
148
+ )
149
+ end
150
+
151
+ def timestamp = Time.now.utc.iso8601
152
+ end
153
+ end
@@ -0,0 +1,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # `insika doctor` (item 23 / §8.1 — OpenClaw's "strict config + doctor --fix",
5
+ # "the repo's best productization discipline"). A read-only diagnosis of a
6
+ # deployment's configuration that turns the scattered, silent boot warnings into
7
+ # ONE structured report — and, with `--fix`, applies the safe autofixes.
8
+ #
9
+ # Every check is DATA (config over convention): an id, a title, and a lambda that
10
+ # inspects the injected collaborators and returns Findings. A Finding may carry a
11
+ # `fix` proc; `fix!` runs those and re-diagnoses. Collaborators are optional so the
12
+ # CLI can build them straight off the durable backend (no full app boot / no
13
+ # DEEPSEEK) and an env-only caller can skip the store checks entirely.
14
+ class Doctor
15
+ # severity: :ok (green) | :info | :warn | :error. `fix` = a 0-arg proc or nil.
16
+ Finding = Data.define(:check, :severity, :message, :fix) do
17
+ def fixable? = !fix.nil?
18
+ def ok? = severity == :ok
19
+ def error? = severity == :error
20
+ def to_h = { "check" => check, "severity" => severity.to_s, "message" => message, "fixable" => fixable? }
21
+ end
22
+
23
+ # The outcome of a run: the findings + convenience rollups + renderers.
24
+ Report = Data.define(:findings) do
25
+ def ok? = errors.empty?
26
+ def errors = findings.select(&:error?)
27
+ def warnings = findings.select { |f| f.severity == :warn }
28
+ def fixable = findings.select(&:fixable?)
29
+
30
+ def to_h
31
+ { "ok" => ok?, "counts" => counts, "findings" => findings.map(&:to_h) }
32
+ end
33
+
34
+ def counts
35
+ findings.each_with_object(Hash.new(0)) { |f, acc| acc[f.severity.to_s] += 1 }
36
+ end
37
+
38
+ # Human report. `color` wraps severities in ANSI when the sink is a TTY.
39
+ def to_s(color: false)
40
+ lines = findings.map { |f| "#{glyph(f.severity, color)} [#{f.check}] #{f.message}#{' (fixable)' if f.fixable?}" }
41
+ summary =
42
+ if ok? && warnings.empty? then "config OK"
43
+ elsif ok? then "config OK — #{warnings.length} warning(s)"
44
+ else "#{errors.length} error(s), #{warnings.length} warning(s)"
45
+ end
46
+ (lines + ["", summary]).join("\n")
47
+ end
48
+
49
+ private
50
+
51
+ def glyph(severity, color)
52
+ sym = { ok: "✓", info: "•", warn: "!", error: "✗" }.fetch(severity, "•")
53
+ return sym unless color
54
+
55
+ code = { ok: 32, info: 36, warn: 33, error: 31 }.fetch(severity, 0)
56
+ "\e[#{code}m#{sym}\e[0m"
57
+ end
58
+ end
59
+
60
+ def initialize(env: ENV, settings_store: nil, llm_provider_store: nil, tool_store: nil,
61
+ agent_file_store: nil, backend: nil, extra_env_specs: [])
62
+ @env = env
63
+ @settings_store = settings_store
64
+ @llm_provider_store = llm_provider_store
65
+ @tool_store = tool_store
66
+ @agent_file_store = agent_file_store
67
+ @backend = backend
68
+ @extra_env_specs = extra_env_specs
69
+ end
70
+
71
+ # -> Report. Never raises (a broken check degrades to an :error Finding).
72
+ def run
73
+ Report.new(findings: checks.flat_map { |check| safe(check) }.freeze)
74
+ end
75
+
76
+ # Applies every fixable finding, then re-diagnoses. -> [Report before, Report after].
77
+ def fix!
78
+ before = run
79
+ before.fixable.map(&:fix).each(&:call)
80
+ [before, run]
81
+ end
82
+
83
+ private
84
+
85
+ def checks = %i[check_env check_settings_schema check_default_model check_db check_llm_provider
86
+ check_admin_token check_data_tools check_prompt_files check_relay_channel
87
+ check_web_widget]
88
+
89
+ def safe(check)
90
+ Array(send(check))
91
+ rescue StandardError => e
92
+ id = check.to_s.sub(/^check_/, "").tr("_", "-")
93
+ [Finding.new(check: id, severity: :error, message: "check crashed: #{e.class}: #{e.message}", fix: nil)]
94
+ end
95
+
96
+ # -- checks --------------------------------------------------------
97
+
98
+ def check_env
99
+ findings = Insika::EnvSchema.validate(@env, extra: @extra_env_specs)
100
+ return [ok("env", "environment: no unknown/invalid keys in owned namespaces")] if findings.empty?
101
+
102
+ findings.map { |f| Finding.new(check: "env", severity: f.severity, message: f.message, fix: nil) }
103
+ end
104
+
105
+ def check_settings_schema
106
+ return [] unless @settings_store
107
+
108
+ pending = @settings_store.pending_migrations
109
+ return [ok("settings-schema", "settings schema at v#{Insika::SettingsStore::SCHEMA_VERSION}")] if pending.empty?
110
+
111
+ [Finding.new(check: "settings-schema", severity: :warn,
112
+ message: "settings schema at v#{@settings_store.stored_schema_version}, pending migration(s) #{pending.inspect} to v#{Insika::SettingsStore::SCHEMA_VERSION}",
113
+ fix: -> { @settings_store.migrate! })]
114
+ end
115
+
116
+ def check_default_model
117
+ return [] unless @settings_store
118
+
119
+ settings = @settings_store.get
120
+ return [ok("default-model", "platform default model: #{settings['default_model']}")] if Insika::Coercion.present?(settings["default_model"])
121
+
122
+ seed = seed_model
123
+ fix = seed && -> { @settings_store.update("default_model" => seed) }
124
+ msg = "no platform default_model set (a model-less agent has nothing to resolve; set one in Studio > Settings)"
125
+ msg += " — fixable from #{seed}" if seed
126
+ [Finding.new(check: "default-model", severity: :warn, message: msg, fix: fix)]
127
+ end
128
+
129
+ def check_db
130
+ unless @backend.is_a?(Insika::Stores::SQLite)
131
+ return [Finding.new(check: "db", severity: :info,
132
+ message: "ephemeral backend (INSIKA_DB unset) — config and state do NOT survive a restart", fix: nil)]
133
+ end
134
+
135
+ @backend.get("__doctor__", "probe") # a read round-trips the handle; raises if the file is broken
136
+ [ok("db", "durable backend: SQLite (readable)")]
137
+ rescue StandardError => e
138
+ [Finding.new(check: "db", severity: :error, message: "SQLite backend not usable: #{e.class}: #{e.message}", fix: nil)]
139
+ end
140
+
141
+ def check_llm_provider
142
+ return [] unless @llm_provider_store
143
+
144
+ configured = !Array(@llm_provider_store.apis).empty? || Insika::Coercion.present?(@env["DEEPSEEK_API_KEY"])
145
+ return [ok("llm-provider", "LLM provider configured")] if configured
146
+
147
+ [Finding.new(check: "llm-provider", severity: :warn,
148
+ message: "no LLM provider configured (set DEEPSEEK_API_KEY or add one in Studio > LLM providers) — turns will fail", fix: nil)]
149
+ end
150
+
151
+ def check_admin_token
152
+ return [ok("admin-token", "ADMIN_TOKEN set")] if Insika::Coercion.present?(@env["ADMIN_TOKEN"])
153
+
154
+ [Finding.new(check: "admin-token", severity: :warn,
155
+ message: "ADMIN_TOKEN unset — /studio is fail-closed (login denied) and the gateway has no fallback token", fix: nil)]
156
+ end
157
+
158
+ # A half-configured relay is the silent kind of broken (RFC-0011 §6): with only
159
+ # the deliver URL set, nothing is mounted and every inbound POST 404s; with only
160
+ # the token, the engine accepts turns it can never answer and the customer waits
161
+ # forever on a reply that is sitting in the outbox. Both halves or neither.
162
+ def check_relay_channel
163
+ token = Insika::Coercion.present?(@env["INSIKA_RELAY_TOKEN"])
164
+ url = Insika::Coercion.present?(@env["INSIKA_RELAY_DELIVER_URL"])
165
+ return [] unless token || url
166
+
167
+ if token && url
168
+ [ok("relay-channel", "relay channel mounted at /channels/relay/events")]
169
+ elsif token
170
+ [Finding.new(check: "relay-channel", severity: :warn,
171
+ message: "INSIKA_RELAY_TOKEN set without INSIKA_RELAY_DELIVER_URL — inbound is accepted but no reply can be delivered", fix: nil)]
172
+ else
173
+ [Finding.new(check: "relay-channel", severity: :warn,
174
+ message: "INSIKA_RELAY_DELIVER_URL set without INSIKA_RELAY_TOKEN — the relay channel is NOT mounted (the token is the switch)", fix: nil)]
175
+ end
176
+ end
177
+
178
+ # The widget is the one PUBLIC channel, so its misconfigurations are the ones that
179
+ # cost money rather than just failing (RFC-0011 §5.3/§5.4). Three of them, in the
180
+ # order they bite: half the switch set (mounted nowhere, or mounted addressing
181
+ # nothing), and a mount with no chat rate limit anywhere — which the channel
182
+ # refuses with a 503 rather than opening, but which reads to an operator as "the
183
+ # widget is broken" unless something says why.
184
+ def check_web_widget
185
+ origins = Insika::Coercion.present?(@env["INSIKA_WIDGET_ORIGINS"])
186
+ agents = Insika::Coercion.present?(@env["INSIKA_WIDGET_AGENTS"])
187
+ return [] unless origins || agents
188
+
189
+ unless origins && agents
190
+ missing = origins ? "INSIKA_WIDGET_AGENTS" : "INSIKA_WIDGET_ORIGINS"
191
+ return [Finding.new(check: "web-widget", severity: :warn,
192
+ message: "#{missing} unset — the web widget is NOT mounted (both allowlists are the switch)", fix: nil)]
193
+ end
194
+
195
+ return [ok("web-widget", "web widget mounted at /channels/web (origins + agents allowlisted, rate limit configured)")] if platform_chat_rate_limit
196
+
197
+ # No platform default. A per-agent `limits.chat_rate_limit` still satisfies the
198
+ # gate, and the profiles are not readable from here, so this is a warning and
199
+ # not an error — but it is the likeliest reason a widget answers 503.
200
+ [Finding.new(check: "web-widget", severity: :warn,
201
+ message: "no platform edge.chat_rate_limit — the widget answers 503 unless EVERY agent in " \
202
+ "INSIKA_WIDGET_AGENTS sets limits.chat_rate_limit (a public channel with no ceiling is not served)", fix: nil)]
203
+ end
204
+
205
+ def platform_chat_rate_limit
206
+ value = ((@settings_store&.get || {})["edge"] || {})["chat_rate_limit"]
207
+ value.to_i.positive?
208
+ end
209
+
210
+ # A stored data-tool whose definition no longer builds is INVISIBLE at runtime: the
211
+ # overlay warns to stderr and drops it, so the agent simply stops seeing the tool.
212
+ # This check is that drop's only report. The one legacy case is auto-fixable: a
213
+ # flat param typed bare `array`, which used to mean "list of strings" — the fix
214
+ # writes that meaning down as `array:string` and changes nothing at runtime.
215
+ # A prompt file holding the Ruby #inspect of a Hash instead of markdown. The write
216
+ # path refuses this now, but a deployment corrupted BEFORE that guard keeps serving
217
+ # the mangled prompt on every turn, and nothing else would ever say so: the file is
218
+ # present, non-empty, and the agent answers — worse than a crash. Found on the pilot
219
+ # by an `insika refine` report, three weeks after the fact.
220
+ def check_prompt_files
221
+ return [] unless @agent_file_store
222
+
223
+ agents = @agent_file_store.agents
224
+ wrapped = agents.flat_map do |agent|
225
+ @agent_file_store.list(agent).filter_map do |name|
226
+ next unless wrapped_content?(@agent_file_store.read(agent, name))
227
+
228
+ Finding.new(check: "prompt-files", severity: :error, fix: nil,
229
+ message: "agent '#{agent}' file '#{name}' holds a serialized object, not text — " \
230
+ "the model receives `{\"content\" => …}` on one line, escapes and all. " \
231
+ "Recover the markdown from inside the wrapper and write it back.")
232
+ end
233
+ end
234
+ return wrapped if wrapped.any?
235
+
236
+ total = agents.sum { |a| @agent_file_store.list(a).length }
237
+ [ok("prompt-files", "#{total} prompt file(s) across #{agents.length} agent(s): all text")]
238
+ end
239
+
240
+ # Cheap and specific: Ruby's inspect of a Hash whose first key is a string. A real
241
+ # prompt does not open with `{"…" =>`.
242
+ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
243
+
244
+ def check_data_tools
245
+ return [] unless @tool_store
246
+
247
+ broken = @tool_store.all_raw.filter_map { |raw| broken_tool(raw) }
248
+ total = @tool_store.names.length
249
+ return [ok("data-tools", "#{total} data tool(s): every definition valid")] if broken.empty?
250
+
251
+ broken
252
+ end
253
+
254
+ def broken_tool(raw)
255
+ Insika::ToolDefinition.from_h(raw)
256
+ nil
257
+ rescue Insika::ValidationError => e
258
+ name = raw.is_a?(Hash) ? raw["name"].to_s : ""
259
+ fix = array_sugar_fix(raw)
260
+ message = "tool '#{name}' is dropped from the catalog: #{e.message}"
261
+ # The autofix preserves the OLD behaviour, which is not always the INTENDED one:
262
+ # a list of objects has been reaching the model declared as a list of strings.
263
+ # Say so, so nobody reads a green doctor as "this tool is right".
264
+ message += " — the fix only writes down what it has been sending (a list of strings); " \
265
+ "if the API expects objects, paste the real JSON Schema in Studio instead" if fix
266
+ Finding.new(check: "data-tools", severity: :error, fix: fix, message: message)
267
+ end
268
+
269
+ # A fix ONLY when spelling the legacy bare `array` explicitly is enough to make the
270
+ # definition build — never a guess at a broken definition we don't understand.
271
+ def array_sugar_fix(raw)
272
+ params = raw.is_a?(Hash) ? raw["parameters"] : nil
273
+ return nil unless params.is_a?(Array) && params.any? { |p| p.is_a?(Hash) && p["type"].to_s == "array" }
274
+
275
+ candidate = raw.merge("parameters" => params.map do |p|
276
+ p.is_a?(Hash) && p["type"].to_s == "array" ? p.merge("type" => "array:string") : p
277
+ end)
278
+ Insika::ToolDefinition.from_h(candidate)
279
+ -> { @tool_store.write(candidate) }
280
+ rescue Insika::ValidationError
281
+ nil
282
+ end
283
+
284
+ # -- helpers -------------------------------------------------------
285
+
286
+ def ok(check, message) = Finding.new(check: check, severity: :ok, message: message, fix: nil)
287
+
288
+ # A model to seed the platform default from: DEEPSEEK_MODEL env, else the first
289
+ # configured provider's default. nil when nothing to seed from.
290
+ def seed_model
291
+ Insika::Coercion.presence(@env["DEEPSEEK_MODEL"])
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module DSL
5
+ # The result of `Insika.agent("id") { … }`. Holds the generated Pack and knows
6
+ # how to run it — but stays gem-free until you actually chat/serve/introspect:
7
+ # the runtime (ruby_llm + the HTTP server) is required lazily inside Runtime.
8
+ #
9
+ # Every runtime path goes through the SAME config-over-code import: the pack is
10
+ # imported (create_agent + write_agent_file/skill/data_tool) into a StoredProfileSource,
11
+ # exactly like any other pack. That is what makes `#profile` identical to a
12
+ # hand-written equivalent pack (the parity spec) — there is only one path.
13
+ class Definition
14
+ attr_reader :pack, :runtime_options
15
+
16
+ def initialize(pack:, runtime: {})
17
+ @pack = pack
18
+ @runtime_options = runtime
19
+ end
20
+
21
+ def id = pack.config[:id].to_s
22
+
23
+ # The generated portable artifact — hand it to any PackImporter and you get
24
+ # this same agent. This is the DSL's real output.
25
+ def to_pack = @pack
26
+
27
+ # The AgentProfile the engine runs, read back from the store after import —
28
+ # so it is byte-for-byte what a hand-written equivalent pack produces.
29
+ def profile = runtime.profile
30
+
31
+ # One turn, in-process, returns the assistant's text. `session:` threads
32
+ # multi-turn memory (a session is created on first use); omit it for a
33
+ # stateless one-shot. Raises on a failed/cancelled turn.
34
+ def reply(message, session: nil, timeout: nil)
35
+ runtime.chat(message, session_id: session, timeout: timeout)
36
+ end
37
+ alias_method :chat, :reply
38
+
39
+ # Boot the control UI (/studio) + the drop-in API (/v1) — the "1 command"
40
+ # of the quickstart. Blocks (runs the reactor) until interrupted.
41
+ def serve(port: 9292, host: "localhost", token: nil, **opts)
42
+ runtime.serve(port: port, host: host, token: token, **opts)
43
+ end
44
+
45
+ # The live runtime graph (Executor + bus + stores) with the pack imported.
46
+ # Memoized: chat/serve/profile share one graph. Pulls in ruby_llm here.
47
+ def runtime
48
+ @runtime ||= begin
49
+ require_relative "runtime"
50
+ Runtime.new(self)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end