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,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # Domain store for sessions. Persists transcript + vars
8
+ # over an injected Insika::Store, with a fixed schema
9
+ # `session:<id>` in the "sessions" scope.
10
+ #
11
+ # The persisted transcript is the SOURCE OF TRUTH for reconstruction; live
12
+ # events are just delivery state. The message shape
13
+ # (`{"role"=>, "content"=>}`, role ∈ user|assistant|system|tool) is the
14
+ # same one `Runner#seed_history` already consumes — the Executor converts nothing.
15
+ #
16
+ # Normalizes symbol→string on WRITE (the backend only guarantees round-trip of
17
+ # JSON types); READ returns the data as it comes from the backend
18
+ # (string keys), never symmetrizing back to symbols.
19
+ class SessionStore
20
+ include Coercion
21
+
22
+ SCOPE = "sessions"
23
+ KEY_PREFIX = "session:"
24
+
25
+ Session = Data.define(:id, :messages, :vars, :memory_refs,
26
+ :created_at, :updated_at)
27
+
28
+ # store: any Insika::Store (Memory, SQLite, ...) — injected by the
29
+ # composition root (config/wiring.rb). SessionStore does not know the
30
+ # concrete backend.
31
+ def initialize(store:)
32
+ @store = store
33
+ end
34
+
35
+ # -> Session; ArgumentError if id already exists (a duplicate session is a
36
+ # domain violation — it never overwrites silently).
37
+ def create(id: SecureRandom.uuid, vars: {})
38
+ key = key_for(id)
39
+ raise ArgumentError, "session already exists: #{id}" unless @store.get(SCOPE, key).nil?
40
+
41
+ now = timestamp
42
+ record = {
43
+ "id" => id.to_s,
44
+ "messages" => [],
45
+ "vars" => deep_stringify(vars),
46
+ "memory_refs" => [],
47
+ "created_at" => now,
48
+ "updated_at" => now
49
+ }
50
+ @store.set(SCOPE, key, record)
51
+ to_session(record)
52
+ end
53
+
54
+ # -> Session | nil
55
+ def find(id)
56
+ record = @store.get(SCOPE, key_for(id))
57
+ record && to_session(record)
58
+ end
59
+
60
+ # -> Session (transcript += messages). Read-modify-write on the task's own
61
+ # fiber, without a lock. Each message gets an "at" (ISO8601 UTC) if not
62
+ # provided. NotFoundError if the session does not exist.
63
+ #
64
+ # CONCURRENCY LIMITATION (§11 R2c): the RMW (read record -> += -> set) is
65
+ # atomic ONLY because the SessionActor serializes turns of the same session
66
+ # (one owner at a time). That serialization exists solely in SUPERVISED mode
67
+ # (the actor loop lives on the supervisor). Two concurrent send_message on the
68
+ # same session_id OUTSIDE that path (e.g. calling append_messages directly, or
69
+ # a non-supervised deployment) would interleave read/set and LOSE messages —
70
+ # there is no compare-and-swap here. Route same-session writes through the
71
+ # SessionActor; see session_actor.rb.
72
+ def append_messages(id, messages)
73
+ record = fetch!(id)
74
+ incoming = (messages.is_a?(Hash) ? [messages] : Array(messages))
75
+ .map { |msg| stamp(deep_stringify(msg)) }
76
+ record["messages"] += incoming
77
+ record["updated_at"] = timestamp
78
+ @store.set(SCOPE, key_for(id), record)
79
+ to_session(record)
80
+ end
81
+
82
+ # -> Session (SHALLOW merge: an existing nested key is replaced wholesale,
83
+ # not merged). NotFoundError if absent.
84
+ def update_vars(id, vars)
85
+ record = fetch!(id)
86
+ record["vars"] = record["vars"].merge(deep_stringify(vars))
87
+ record["updated_at"] = timestamp
88
+ @store.set(SCOPE, key_for(id), record)
89
+ to_session(record)
90
+ end
91
+
92
+ # -> bool (delegates to the backend: false for a nonexistent id)
93
+ def delete(id)
94
+ @store.delete(SCOPE, key_for(id))
95
+ end
96
+
97
+ # -> enumerates ids without the "session:" prefix. Without a block,
98
+ # returns an Enumerator.
99
+ def each_id
100
+ return enum_for(:each_id) unless block_given?
101
+
102
+ @store.list(SCOPE, KEY_PREFIX).each do |key|
103
+ yield key.delete_prefix(KEY_PREFIX)
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def key_for(id)
110
+ "#{KEY_PREFIX}#{id}"
111
+ end
112
+
113
+ # Loads the raw record; NotFoundError if absent (nonexistent session ->
114
+ # HTTP 404). Backend errors (StoreError) propagate without re-wrapping.
115
+ def fetch!(id)
116
+ record = @store.get(SCOPE, key_for(id))
117
+ raise Insika::NotFoundError, "session not found: #{id}" if record.nil?
118
+
119
+ record
120
+ end
121
+
122
+ def to_session(record)
123
+ Session.new(
124
+ id: record["id"],
125
+ messages: record["messages"],
126
+ vars: record["vars"],
127
+ memory_refs: record["memory_refs"],
128
+ created_at: record["created_at"],
129
+ updated_at: record["updated_at"]
130
+ )
131
+ end
132
+
133
+ # Stamps "at" (ISO8601 UTC) on the message when absent; preserves whatever is provided.
134
+ def stamp(message)
135
+ message["at"] ||= timestamp
136
+ message
137
+ end
138
+
139
+ def timestamp
140
+ Time.now.utc.iso8601
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # GENERAL deploy settings: timeouts, streaming and
5
+ # compaction. A single record in the ConfigStore (scope "settings", key "general").
6
+ # A read always returns the DEFAULTS overlaid by whatever was authored — so
7
+ # a brand-new deploy (empty store) already responds with coherent config, and the Studio
8
+ # only persists the delta. Shallow merge at the top, deep in `compaction` (sub-hash).
9
+ #
10
+ # Not to be confused with the transport `CONFIG` (bind/port/token, ENV+freeze at
11
+ # boot): this is editable RUNTIME config, durable in the same backend.
12
+ class SettingsStore
13
+ SCOPE = "settings"
14
+ KEY = "general"
15
+
16
+ # STRICT config, settings layer (item 23 / §8.1 — "no silent config compat: every
17
+ # schema migration explicit"). The settings record carries a `schema_version`;
18
+ # every shape change is a numbered migration here, applied ONLY by the explicit
19
+ # `migrate!` (Studio settings saves never silently reinterpret old-shaped data).
20
+ # v1 is the baseline: a pre-versioning record (no `schema_version`) reads as 0 and
21
+ # `insika doctor --fix` stamps it to 1. Add a real change as MIGRATIONS[2] = proc
22
+ # and bump SCHEMA_VERSION.
23
+ SCHEMA_VERSION = 1
24
+ MIGRATIONS = {}.freeze # target_version(Integer) => ->(record_hash){ migrated_hash }
25
+
26
+ DEFAULTS = {
27
+ "schema_version" => SCHEMA_VERSION,
28
+ "streaming" => true,
29
+ "request_timeout" => 120,
30
+ "max_retries" => 2,
31
+ "turn_timeout" => 120,
32
+ "tool_timeout" => 30,
33
+ "compaction" => { "enabled" => false, "keep_last" => 20 },
34
+ # LLM config v2 (§10). Platform-wide model layer, resolved by the
35
+ # ModelResolver under an agent that pins no model of its own:
36
+ # default_model/default_provider -> the platform default (Chat > Agent > HERE)
37
+ # fallback_models -> ordered chain ["provider/model" | "model", ...] tried
38
+ # when the primary is NOT a user pin (source semantics)
39
+ # utility_model -> slot for cheap internal tasks (titles, distillation,
40
+ # compaction); reserved for later wiring.
41
+ "default_model" => nil,
42
+ "default_provider" => nil,
43
+ "fallback_models" => [],
44
+ "utility_model" => nil,
45
+ # Reasoning control (§10, 4-layer: Chat > Agent > Model > Global). `thinking`
46
+ # is the GLOBAL default (off/on/low/medium/high; nil = provider default);
47
+ # `model_params` is the PER-MODEL layer, a map "<provider/model>"|"<model>" ->
48
+ # { "thinking" => ... }. Both resolved by the ModelResolver.
49
+ "thinking" => nil,
50
+ "model_params" => {},
51
+ # Evals (RFC-0008, panel by RFC-0013 §3.9). The GRADERS are platform config, so
52
+ # the operator picks them in the Studio instead of remembering a CLI flag:
53
+ # judges -> [{ "model" =>, "provider" => }, …]. [] = deterministic
54
+ # asserts only (rubric'd cases read as judge_pending).
55
+ # aggregate -> median | mean | min — how the panel's scores become the one
56
+ # the report and the baseline read.
57
+ # min_agreement -> fraction of judges that must pass for the case to pass.
58
+ # quorum -> samples per judge (variance), on top of the panel.
59
+ # tolerance -> max judge-score drop before it counts as a regression.
60
+ # Additive key: reads overlay DEFAULTS, so no numbered migration is due (only a
61
+ # later SHAPE change to this key would earn one).
62
+ "evals" => {
63
+ "judges" => [],
64
+ "aggregate" => "median",
65
+ "min_agreement" => 0.5,
66
+ "quorum" => 1,
67
+ "tolerance" => 0.05
68
+ },
69
+ # Edge limits (item 33 / §12 G7) — the platform layer of the EdgeLimiter.
70
+ # nil/0 = off (opt-in). chat_rate_limit = turn attempts per chat per
71
+ # chat_rate_window (s); agent_token_ceiling = total tokens per agent per
72
+ # agent_token_window (s). limit_response overrides the safe reply.
73
+ # Per-agent overrides live in profile.limits (same keys, sans windows).
74
+ "edge" => {
75
+ "chat_rate_limit" => nil,
76
+ "chat_rate_window" => 60,
77
+ "agent_token_ceiling" => nil,
78
+ "agent_token_window" => 86_400,
79
+ "limit_response" => nil
80
+ }
81
+ }.freeze
82
+
83
+ def initialize(config_store:)
84
+ @cs = config_store
85
+ end
86
+
87
+ # -> Hash (defaults overlaid by the authored values). String keys (Store contract).
88
+ def get
89
+ deep_merge(DEFAULTS, stored)
90
+ end
91
+
92
+ # Merges the patch over the current one and persists. -> Hash (resulting settings).
93
+ # Unknown keys are preserved (the Studio decides the screen's schema).
94
+ def update(patch)
95
+ merged = deep_merge(get, stringify(patch || {}))
96
+ @cs.put(SCOPE, KEY, merged)
97
+ merged
98
+ end
99
+
100
+ # RAW persisted schema version (bypasses the DEFAULTS overlay, which would always
101
+ # report the current one). nil = no settings persisted yet (fresh deploy — nothing
102
+ # to migrate); an Integer otherwise, 0 for a pre-versioning record.
103
+ def stored_schema_version
104
+ raw = stored
105
+ return nil if raw.empty?
106
+
107
+ Integer(raw["schema_version"] || 0)
108
+ end
109
+
110
+ # Which numbered migrations still need to run. [] when fresh or already current.
111
+ def pending_migrations
112
+ from = stored_schema_version
113
+ return [] if from.nil? || from >= SCHEMA_VERSION
114
+
115
+ ((from + 1)..SCHEMA_VERSION).to_a
116
+ end
117
+
118
+ # Applies the pending migrations EXPLICITLY (in order) and stamps the version. No-op
119
+ # when fresh or already current. -> resulting schema_version (Integer).
120
+ def migrate!
121
+ data = stored
122
+ return SCHEMA_VERSION if data.empty?
123
+
124
+ pending_migrations.each do |version|
125
+ migration = MIGRATIONS[version]
126
+ data = stringify(migration.call(data)) if migration
127
+ end
128
+ data["schema_version"] = SCHEMA_VERSION
129
+ @cs.put(SCOPE, KEY, data)
130
+ SCHEMA_VERSION
131
+ end
132
+
133
+ private
134
+
135
+ def stored
136
+ @cs.get(SCOPE, KEY) || {}
137
+ end
138
+
139
+ def deep_merge(base, override)
140
+ base.merge(override) do |_key, a, b|
141
+ a.is_a?(Hash) && b.is_a?(Hash) ? deep_merge(a, b) : b
142
+ end
143
+ end
144
+
145
+ def stringify(obj)
146
+ case obj
147
+ when Hash then obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = stringify(v) }
148
+ when Array then obj.map { |v| stringify(v) }
149
+ when Symbol then obj.to_s
150
+ else obj
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # RFC-0016 A3 — shutdown is a drain, not a kill (docs/DEPLOY.md, process model
5
+ # item 4). The serving arms install this around the Executor. On the first
6
+ # SIGTERM/SIGINT the process stops accepting new turns (`Executor#begin_drain!`
7
+ # — a turn arriving mid-drain is left `:queued` for the next boot's recovery)
8
+ # and waits up to `timeout` seconds for the in-flight ones; only then does the
9
+ # ordinary stop proceed. A second signal skips the wait — the operator insisting
10
+ # means now. Whatever the deadline abandons dies `:running` with the process and
11
+ # the next boot generation's task sweep replays it from its checkpoint
12
+ # (side-effect skip on resume is what makes that replay safe, RFC-0006).
13
+ #
14
+ # Mechanics, because trap context is narrow: the handler writes ONE byte into a
15
+ # self-pipe and returns. A plain watcher THREAD — not a fiber: at install time
16
+ # the serving reactor may not exist yet, and the drain must not depend on it —
17
+ # blocks on that pipe, runs the drain, and then delivers the stop the trap
18
+ # withheld by raising Interrupt in the serving thread. That is exactly the
19
+ # exception async-container's own trap would have raised, so everything
20
+ # downstream (Falcon worker teardown, reactor close, Litestream's final sync)
21
+ # is unchanged; this class only buys the turns time before it.
22
+ class Shutdown
23
+ DEFAULT_TIMEOUT = 20 # seconds — the number DEPLOY.md records and entrypoint.sh builds on
24
+ POLL = 0.05
25
+
26
+ # The one-call form the serving arms use: resolves the deadline, sets the
27
+ # traps, parks the watcher. The CALLING thread is captured as the stop target
28
+ # — install from the thread that runs the server.
29
+ #
30
+ # `executors:` (RFC-0017 A4) drains N graphs on one signal. Signals are a
31
+ # PROCESS concern, and `Signal.trap` keeps only the last handler — so a second
32
+ # `install` per graph would silently leave the earlier graphs dying mid-turn.
33
+ # The host installs ONCE, naming every graph it embedded. `executor:` is the
34
+ # single-graph sugar every serving arm still uses.
35
+ def self.install(executor: nil, executors: nil, timeout: nil, logger: $stdout, signals: %w[INT TERM])
36
+ new(executors: executors || executor, timeout: timeout || default_timeout,
37
+ logger: logger).install!(signals)
38
+ end
39
+
40
+ # INSIKA_DRAIN_TIMEOUT (validated by EnvSchema); absent/garbage -> the default.
41
+ def self.default_timeout
42
+ Integer(EnvSchema.read("INSIKA_DRAIN_TIMEOUT").to_s)
43
+ rescue ArgumentError
44
+ DEFAULT_TIMEOUT
45
+ end
46
+
47
+ # `interrupt` is the injectable stop delivery (specs); the default raises
48
+ # Interrupt in `target`, the thread that installed the traps.
49
+ def initialize(executor: nil, executors: nil, timeout:, logger: $stdout,
50
+ target: Thread.current, interrupt: nil)
51
+ @executors = Array(executors || executor)
52
+ @timeout = timeout
53
+ @logger = logger
54
+ @target = target
55
+ @interrupt = interrupt || -> { @target.raise(Interrupt) }
56
+ @reader, @writer = IO.pipe
57
+ @signaled = false
58
+ end
59
+
60
+ attr_reader :timeout
61
+
62
+ def install!(signals = %w[INT TERM])
63
+ signals.each { |sig| Signal.trap(sig) { signal_received } }
64
+ @thread = Thread.new { watch }
65
+ @thread.name = "insika-shutdown"
66
+ self
67
+ end
68
+
69
+ # Runs in TRAP context: a flag flip and a pipe write, nothing else — except
70
+ # when the drain is already underway, where a repeated signal means "stop
71
+ # waiting" and the Interrupt is delivered immediately.
72
+ def signal_received
73
+ return @interrupt.call if @signaled
74
+
75
+ @signaled = true
76
+ begin
77
+ @writer.write_nonblock("!")
78
+ rescue IOError, SystemCallError
79
+ nil # pipe gone (process already unwinding): nothing left to schedule
80
+ end
81
+ end
82
+
83
+ # The watcher's whole life: parked on the pipe until the first signal, then
84
+ # drain and hand the stop back to the normal path.
85
+ def watch
86
+ @reader.read(1)
87
+ drain
88
+ @interrupt.call
89
+ end
90
+
91
+ # Closes the intake and waits for the in-flight turns, bounded by the
92
+ # deadline. With N executors the intake of EVERY one closes first, before any
93
+ # waiting: draining them in sequence would let graph B keep taking turns while
94
+ # graph A spends the deadline. -> { drained: bool, abandoned: [task ids] }
95
+ def drain
96
+ @executors.each(&:begin_drain!)
97
+ log("shutdown: draining — #{in_flight.size} turn(s) in flight, deadline #{@timeout}s")
98
+ deadline = monotonic + @timeout
99
+ sleep(POLL) until in_flight.empty? || monotonic >= deadline
100
+
101
+ abandoned = in_flight
102
+ if abandoned.empty?
103
+ log("shutdown: drained clean")
104
+ else
105
+ log("shutdown: deadline reached — abandoning #{abandoned.size} turn(s); " \
106
+ "recovery replays them at the next boot")
107
+ end
108
+ { drained: abandoned.empty?, abandoned: abandoned }
109
+ end
110
+
111
+ private
112
+
113
+ # The turns still running across every graph this shutdown owns.
114
+ def in_flight = @executors.flat_map(&:in_flight)
115
+
116
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
117
+
118
+ # Observability only: a logger failure must never alter the drain.
119
+ def log(message)
120
+ @logger&.puts(message)
121
+ rescue StandardError
122
+ nil
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Insika
6
+ # OpenClaw / AgentSkills convention: each skill is a directory with a
7
+ # SKILL.md (YAML frontmatter + markdown body). Progressive disclosure:
8
+ # level 1 = name+description in the system prompt; level 2 = body loaded
9
+ # on demand by the load_skill tool.
10
+ #
11
+ # Consumed by the Executor (skill_catalog:) and by stage 3
12
+ # (effective/format_for_prompt).
13
+ class SkillCatalog
14
+ Skill = Data.define(:name, :description, :path, :body)
15
+
16
+ # roots ordered by PRECEDENCE (highest first): workspace, managed,
17
+ # bundled. Same name in more than one root: the first wins.
18
+ #
19
+ # store (optional): a SkillStore with the skills AUTHORED in the Studio.
20
+ # They overlay the on-disk ones (seed) — the Store wins, it is the source of truth.
21
+ # Nil = disk-only behavior, zero regression.
22
+ def initialize(roots, store: nil)
23
+ @roots = Array(roots)
24
+ @store = store
25
+ @skills = load_all
26
+ end
27
+
28
+ def all
29
+ @skills.values
30
+ end
31
+
32
+ def find(name)
33
+ @skills[name.to_s]
34
+ end
35
+
36
+ # Reloads from disk + Store and SWAPS the index atomically: an
37
+ # authored/edited skill takes effect without a restart. A turn in progress
38
+ # captured @skills at dispatch, so it does not see the swap mid-flight.
39
+ def reload
40
+ @skills = load_all
41
+ self
42
+ end
43
+
44
+ # Per-agent allowlist: nil -> all | [] -> none | [names] -> subset.
45
+ def effective(skills_policy)
46
+ Allowlist.filter(all, skills_policy) { |s| s.name }
47
+ end
48
+
49
+ # Level 1: compact list injected into the system prompt. Metadata only.
50
+ # Receives the set already filtered by the agent.
51
+ def format_for_prompt(skills = all)
52
+ return "" if skills.empty?
53
+
54
+ entries = skills.map do |s|
55
+ %( <skill name="#{s.name}">#{s.description}</skill>)
56
+ end.join("\n")
57
+
58
+ <<~PROMPT.strip
59
+ <available_skills>
60
+ #{entries}
61
+ </available_skills>
62
+
63
+ Before acting on a task that matches a skill above, call the
64
+ `load_skill` tool with its name to load the complete instructions.
65
+ PROMPT
66
+ end
67
+
68
+ private
69
+
70
+ def load_all
71
+ found = {}
72
+ @roots.each do |root|
73
+ Dir.glob(File.join(root, "**", "SKILL.md")).sort.each do |file|
74
+ skill = parse_content(File.read(file, encoding: "UTF-8"), path: file)
75
+ next unless skill
76
+
77
+ found[skill.name] ||= skill # precedence: first root wins
78
+ end
79
+ end
80
+ overlay_store(found)
81
+ found
82
+ end
83
+
84
+ # Store skills overlay the on-disk ones (authored > seed). Sentinel path
85
+ # "store:<name>" — not a real file (load_skill uses `body`, not the path).
86
+ def overlay_store(found)
87
+ return unless @store
88
+
89
+ @store.all.each do |name, content|
90
+ skill = parse_content(content.to_s, path: "store:#{name}")
91
+ found[skill.name] = skill if skill # Store wins
92
+ end
93
+ end
94
+
95
+ def parse_content(raw, path:)
96
+ match = raw.match(/\A---\s*\n(.*?)\n---\s*\n(.*)\z/m)
97
+ return nil unless match
98
+
99
+ # Tolerant frontmatter: real packs have `: ` in the description prose, which
100
+ # strict YAML rejected (the pack would not load).
101
+ meta = Insika::Frontmatter.parse(match[1])
102
+ name = meta["name"]
103
+ return nil unless name
104
+
105
+ Skill.new(
106
+ name: name.to_s,
107
+ description: meta["description"].to_s,
108
+ path: path,
109
+ body: match[2].strip
110
+ )
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # AUTHORED shared skills.
7
+ # Holds the complete SKILL.md (frontmatter + body) in the durable Store. The
8
+ # SkillCatalog overlays these skills on top of the on-disk ones (seed), with the Store
9
+ # winning — so editing/creating a skill in the Studio takes effect without a restart (via reload).
10
+ #
11
+ # One record per skill in the ConfigStore (scope "skills"):
12
+ # { "content" => "<entire SKILL.md>",
13
+ # "updated_at" => iso8601,
14
+ # "history" => [ { "content" =>, "at" => }, ... ] }
15
+ #
16
+ # The key is the skill's canonical name (the same as in the frontmatter). Versions like
17
+ # the AgentFileStore.
18
+ class SkillStore
19
+ SCOPE = "skills"
20
+ HISTORY_MAX = 20
21
+
22
+ def initialize(config_store:)
23
+ @cs = config_store
24
+ end
25
+
26
+ # -> String | nil (complete SKILL.md).
27
+ def get(name)
28
+ record(name)&.fetch("content", nil)
29
+ end
30
+
31
+ # -> [String] names, lexicographic order.
32
+ def names = @cs.keys(SCOPE)
33
+
34
+ # -> { name => content } of all authored skills.
35
+ def all
36
+ @cs.keys(SCOPE).each_with_object({}) { |n, acc| acc[n] = get(n) }
37
+ end
38
+
39
+ # Writes (upsert). create_only refuses to overwrite. -> Hash (the stored record).
40
+ def write(name, content, create_only: false)
41
+ key = name.to_s
42
+ current = @cs.get(SCOPE, key)
43
+ raise Insika::ValidationError, "skill '#{key}' already exists" if create_only && current
44
+
45
+ rec = build_record(content.to_s, current)
46
+ @cs.put(SCOPE, key, rec)
47
+ rec
48
+ end
49
+
50
+ # -> bool (did it exist?).
51
+ def delete(name) = @cs.delete(SCOPE, name.to_s)
52
+
53
+ # -> [ { "content" =>, "at" => } ] most recent first.
54
+ def versions(name) = record(name)&.fetch("history", []) || []
55
+
56
+ # Restores version `index` as the current content (a new write). -> Hash.
57
+ def restore(name, index)
58
+ hist = versions(name)
59
+ i = Integer(index)
60
+ raise Insika::NotFoundError, "skill '#{name}' not found" unless record(name)
61
+ raise Insika::ValidationError, "version #{index} does not exist" if i.negative? || i >= hist.length
62
+
63
+ write(name, hist[i]["content"])
64
+ end
65
+
66
+ private
67
+
68
+ def record(name) = @cs.get(SCOPE, name.to_s)
69
+
70
+ def build_record(content, current)
71
+ history = current ? current.fetch("history", []) : []
72
+ if current
73
+ history = [{ "content" => current["content"], "at" => current["updated_at"] }] + history
74
+ history = history.first(HISTORY_MAX)
75
+ end
76
+ { "content" => content, "updated_at" => Time.now.utc.iso8601, "history" => history }
77
+ end
78
+ end
79
+ end