insika 0.0.1 → 0.2.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 (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -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 +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -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 +375 -0
  22. data/docs/SKILLS.md +284 -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 +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. metadata +412 -8
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/queue"
5
+ require "time" # Time#iso8601 for the arrival record on :turn_coalesced
6
+
7
+ module Insika
8
+ # Sessions as Actors: one fiber per session with a FIFO queue
9
+ # of turns, executed ONE AT A TIME. Restores the "one owner at a time"
10
+ # invariant of the transcript that two concurrent `send_message` calls on the
11
+ # same `session_id` would break (read-modify-write on the Session Store). Turns
12
+ # from distinct sessions stay concurrent; one-shot/history (no session_id) do
13
+ # not go through here.
14
+ #
15
+ # Lives in the SUPERVISED scope: the loop is a child of the supervisor, not of
16
+ # the request — it outlives the connection. The turn itself (spawned by the
17
+ # Executor) is also born on the supervisor; the SessionActor only AWAITS it to
18
+ # serialize.
19
+ #
20
+ # it is also where an inbound message for a BUSY session is routed.
21
+ # That decision belongs here and nowhere else — this is already the object that
22
+ # owns "one turn at a time for this session". Putting it in the HTTP handler
23
+ # would duplicate the invariant; putting it in the Executor would mix turn
24
+ # execution with queue policy.
25
+ class SessionActor
26
+ def initialize(session_id:, executor:, parent: Async::Task.current)
27
+ @session_id = session_id
28
+ @executor = executor
29
+ @queue = Async::Queue.new
30
+ @running = false
31
+ # The turn currently sitting at the door: created and :queued, but not yet
32
+ # released to run. `collect` merges into THIS one. nil whenever there is
33
+ # nothing mergeable — which is the common case and the safe default.
34
+ @pending = nil
35
+ @loop = parent.async { |t| t.annotate("session:#{session_id}"); run_loop }
36
+ end
37
+
38
+ # Enqueues a turn (FIFO). Non-blocking: the handler responds with an
39
+ # immediate {task_id:} even if the turn stays :queued behind another. -> task.id.
40
+ #
41
+ # `policy` (a QueuePolicy) opens the debounce window for this turn; nil or a
42
+ # policy without a window behaves exactly as before — dequeued and run at once.
43
+ def enqueue(task, profile:, resume_from: nil, policy: nil)
44
+ @queue.enqueue([task, profile, resume_from, policy])
45
+ task.id
46
+ end
47
+
48
+ # merge a fragment into the turn waiting at the door.
49
+ # -> the task id it joined, or nil when there is nothing to merge into (no
50
+ # pending turn, the window has closed, or the turn already started). nil is
51
+ # the caller's signal to create a task of its own.
52
+ #
53
+ # Runs on the REQUEST's fiber, not the loop's; both are on the same reactor
54
+ # and neither yields between the check and the write below, so the "is it
55
+ # still mergeable" test and the append cannot interleave.
56
+ def collect(text)
57
+ pending = @pending
58
+ return nil if pending.nil?
59
+
60
+ @executor.task_store.append_message(pending[:task_id], text)
61
+ pending[:count] += 1
62
+ # A merged fragment leaves NO task of its own (see #hold_at_the_door), so this
63
+ # is the only record that it arrived as a separate message. Kept as arrival
64
+ # times — never the text — and shipped on :turn_coalesced, so "the customer
65
+ # says they sent the order number" is answerable without the store carrying an
66
+ # orphan task per fragment.
67
+ pending[:arrivals] << Time.now.utc.iso8601
68
+ pending[:version] += 1 # tells a sleeping debounce window that more arrived
69
+ pending[:task_id]
70
+ rescue ArgumentError
71
+ # The turn left :queued between the read of @pending and the append (it was
72
+ # released while we were deciding). Not an error: the caller falls back to
73
+ # creating its own task, which is exactly `followup`.
74
+ nil
75
+ end
76
+
77
+ def running? = @running
78
+ def depth = @queue.size
79
+
80
+ # The turn this session is running RIGHT NOW, or nil when idle or still at the
81
+ # door. `steer` needs the Task itself and not just its id: whether a turn can
82
+ # absorb a message at all depends on what kind of turn it is (a workflow has no
83
+ # chat), and reading that off the object avoids a store round-trip on the
84
+ # request's path.
85
+ attr_reader :current_task
86
+
87
+ # Is there a turn at the door that `collect` could still merge into?
88
+ def collecting? = !@pending.nil?
89
+
90
+ # Is the loop still alive? (the Executor revalidates before reusing from the
91
+ # cache — a dead loop would black-hole queued turns).
92
+ def alive? = !!@loop&.running?
93
+
94
+ # Shuts down the loop (server shutdown / tests — the loop blocks forever on
95
+ # dequeue when idle).
96
+ def stop = @loop&.stop
97
+
98
+ private
99
+
100
+ def run_loop
101
+ loop do
102
+ task, profile, resume_from, policy = @queue.dequeue # blocks when empty
103
+ task = hold_at_the_door(task, policy)
104
+ @running = true
105
+ @current_task = task
106
+ begin
107
+ @executor.run_serial(task, profile: profile, resume_from: resume_from)
108
+ rescue StandardError
109
+ # run_serial already maps turn errors; this rescue is defense: an
110
+ # unexpected error must NEVER bring down the session loop (Async::Stop <
111
+ # Exception is not captured -> #stop ends the loop normally).
112
+ nil
113
+ ensure
114
+ @running = false
115
+ @current_task = nil
116
+ end
117
+ end
118
+ end
119
+
120
+ # the debounce window. Sleeps on the LOOP's fiber, never on the
121
+ # request's, so the POST is acked immediately and the platform does not retry.
122
+ # Returns the task to run (re-read from the store when fragments merged into it,
123
+ # since the in-memory Task is a frozen snapshot of an older message).
124
+ def hold_at_the_door(task, policy)
125
+ return task unless policy&.debounce?
126
+
127
+ @pending = { task_id: task.id, count: 1, version: 0, arrivals: [Time.now.utc.iso8601] }
128
+ begin
129
+ wait_for_quiet(policy)
130
+ merged = @pending[:count]
131
+ arrivals = @pending[:arrivals]
132
+ ensure
133
+ # The window is closed BEFORE the turn runs, under every exit path: a
134
+ # `collect` that slipped in here would append to a task about to be read.
135
+ @pending = nil
136
+ end
137
+
138
+ return task if merged == 1
139
+
140
+ @executor.emit_coalesced(task, merged: merged, arrivals: arrivals)
141
+ @executor.task_store.find(task.id) || task
142
+ end
143
+
144
+ # Sleeps in `debounce_ms` slices, restarting whenever a fragment arrives
145
+ # (`version` moved), until either a slice passes in silence or the total
146
+ # deferral reaches `debounce_max_ms` — the ceiling that stops a customer who
147
+ # keeps typing from postponing their own answer forever.
148
+ def wait_for_quiet(policy)
149
+ quiet = policy.debounce_ms / 1000.0
150
+ deadline = monotonic + (policy.debounce_max_ms / 1000.0)
151
+
152
+ loop do
153
+ mark = @pending[:version]
154
+ Async::Task.current.sleep(quiet)
155
+ break if @pending[:version] == mark # a full slice of silence
156
+ break if monotonic >= deadline
157
+ end
158
+ end
159
+
160
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
161
+ end
162
+ end
@@ -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 (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 (— "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. 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 (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 (panel by). 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 — 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
+ # shutdown is a drain, not a kill (docs/DEPLOY.md, process model
5
+ # 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).
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:` 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