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,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # Called ONCE at boot, BEFORE accepting requests. Discovers
8
+ # interrupted tasks and dispatches the resume through the SAME path as
9
+ # ResumeTask — this component executes nothing, opens no Execution, changes no
10
+ # status of resumable tasks. Just discovery + dispatch + marking of
11
+ # unrecoverable tasks.
12
+ #
13
+ # Durability without an external job runner = stores +
14
+ # recovery at boot. The "execution" half is the ResumeTask handler.
15
+ #
16
+ # `command_bus` is consumed only through the `dispatch(command)` contract.
17
+ class Recovery
18
+ SWEEP_SCOPE = "recovery"
19
+
20
+ # The per-boot-generation sweep claim. N workers share one
21
+ # store, and the sweep's "orphaned :running" test is per-process: a worker
22
+ # booting while a sibling holds a live turn would see it as an orphan and
23
+ # re-run it. So the TASK sweep runs once per boot generation — the first
24
+ # worker to claim `boot_id` sweeps, the rest skip; a worker respawned
25
+ # mid-generation skips too (its own orphans wait for the next generation).
26
+ # Rides Store#transaction like every claim. nil/empty boot_id (single
27
+ # process: DSL serve, scripts, tests) -> always true, every boot sweeps.
28
+ def self.claim_sweep(store:, boot_id:)
29
+ id = boot_id.to_s
30
+ return true if id.empty?
31
+
32
+ store.transaction do
33
+ key = "sweep:#{id}"
34
+ if store.get(SWEEP_SCOPE, key).nil?
35
+ store.set(SWEEP_SCOPE, key, { "claimed_at" => Time.now.utc.iso8601 })
36
+ true
37
+ else
38
+ false
39
+ end
40
+ end
41
+ end
42
+
43
+ # checkpoint_store: needed to query `latest`. logger optional
44
+ # (default nil -> silent in tests).
45
+ def initialize(task_store:, checkpoint_store:, command_bus:, logger: nil)
46
+ @task_store = task_store
47
+ @checkpoint_store = checkpoint_store
48
+ @command_bus = command_bus
49
+ @logger = logger
50
+ end
51
+
52
+ # -> { resumed: [ids], failed: [ids] }
53
+ # The initial sweep runs OUTSIDE the per-task rescue: a StoreError here
54
+ # aborts the boot.
55
+ #
56
+ # stale_after (seconds): the periodic tick's semantics instead of
57
+ # boot's. Only :queued/:running tasks untouched for longer than that are
58
+ # candidates — :waiting/:paused are idle by nature (a human wait), so
59
+ # staleness cannot tell a live one from a dead one, and they stay boot
60
+ # recovery's. The threshold exists because a live :running turn is bounded
61
+ # by turn_timeout: anything older cannot be alive.
62
+ def run(stale_after: nil)
63
+ resumed = []
64
+ failed = []
65
+ # Ordered by created_at: tasks from the SAME session are reprocessed
66
+ # in their original order. Global time ordering is harmless for standalone tasks.
67
+ # 1) interrupted (crash mid-flight) -> resume from the checkpoint. Tick mode
68
+ # narrows this to :running — the only mid-flight state staleness can judge.
69
+ running = stale_after ? @task_store.with_status(:running) : @task_store.running_or_interrupted
70
+ sweep(running, stale_after).each { |task| process(task, resumed, failed, tick: !stale_after.nil?) }
71
+ # 2) queued but never started (turn in the SessionActor queue at crash time)
72
+ # -> re-run from scratch (the same resume_task handles :queued). Without
73
+ # this, a :queued turn in the volatile queue would be lost on kill -9.
74
+ sweep(@task_store.queued, stale_after).each { |task| process(task, resumed, failed, tick: !stale_after.nil?) }
75
+ log(:info, "recovery finished: #{resumed.size} resumed, #{failed.size} failed")
76
+ { resumed: resumed, failed: failed }
77
+ end
78
+
79
+ private
80
+
81
+ # Boot mode (stale_after nil) takes the candidate list as-is; tick mode keeps
82
+ # only tasks untouched past the threshold — the liveness gate of.
83
+ def sweep(tasks, stale_after)
84
+ tasks = tasks.sort_by(&:created_at)
85
+ return tasks unless stale_after
86
+
87
+ cutoff = Time.now.utc - stale_after
88
+ tasks.select { |task| stale?(task, cutoff) }
89
+ end
90
+
91
+ def stale?(task, cutoff)
92
+ Time.iso8601(task.updated_at.to_s) < cutoff
93
+ rescue ArgumentError
94
+ true # an unreadable timestamp is not proof of life — treat as stale
95
+ end
96
+
97
+ # Failing to resume ONE task does not bring down the boot: a non-store
98
+ # dispatch/latest error -> mark :failed and continue. StoreError -> propagate
99
+ # (aborts the boot).
100
+ #
101
+ # tick: true flips ONE rescue: a ValidationError from the dispatch
102
+ # is ResumeTask's local liveness check ("task is running") — someone alive
103
+ # owns it, the normal case on a timer, so the task is SKIPPED for the next
104
+ # tick. At boot a ValidationError means corruption (nothing may be alive),
105
+ # so it still fails the task there. This is the trap defused in-process:
106
+ # the tick must never murder a live turn with its own recovery.
107
+ def process(task, resumed, failed, tick: false)
108
+ # :queued never started (no checkpoint) but IS recoverable — ResumeTask
109
+ # re-runs from the Command. An interrupted task requires a checkpoint;
110
+ # without one, it is unrecoverable.
111
+ if task.status == :queued || @checkpoint_store.latest(task.id)
112
+ @command_bus.dispatch(resume_command(task.id))
113
+ resumed << task.id
114
+ log(:info, "resume dispatched: #{task.id}")
115
+ else
116
+ fail_task(task.id, class_name: "Insika::Error",
117
+ message: "unrecoverable: no checkpoint")
118
+ failed << task.id
119
+ log(:warn, "unrecoverable (no checkpoint): #{task.id}")
120
+ end
121
+ rescue Insika::StoreError
122
+ raise
123
+ rescue Insika::ValidationError => e
124
+ # Boot keeps the old contract (corruption -> :failed); the tick skips.
125
+ if tick
126
+ log(:info, "skipped (alive elsewhere): #{task.id} — #{e.message}")
127
+ else
128
+ fail_task(task.id, class_name: e.class.name, message: e.message, stage: "recovery")
129
+ failed << task.id unless failed.include?(task.id)
130
+ log(:warn, "failed to resume #{task.id}: #{e.class}: #{e.message}")
131
+ end
132
+ rescue StandardError => e
133
+ fail_task(task.id, class_name: e.class.name, message: e.message, stage: "recovery")
134
+ failed << task.id unless failed.include?(task.id)
135
+ log(:warn, "failed to resume #{task.id}: #{e.class}: #{e.message}")
136
+ end
137
+
138
+ # Transitions the task to :failed, writing the error into the open Execution
139
+ # (if any). StoreError propagates (aborts the boot). ArgumentError is
140
+ # absorbed: `paused -> failed` is not in the machine — the task stays in its
141
+ # current state, but we still report it as failed in the summary. Do not
142
+ # "fix" the machine here.
143
+ def fail_task(id, class_name:, message:, stage: nil)
144
+ error = { class: class_name, message: message }
145
+ error[:stage] = stage if stage
146
+ @task_store.transition(id, to: :failed, error: error)
147
+ rescue Insika::StoreError
148
+ raise
149
+ rescue ArgumentError
150
+ nil
151
+ end
152
+
153
+ def resume_command(task_id)
154
+ # transport: :recovery identifies the origin (boot) in meta — auditing
155
+ # (the field is a free Symbol).
156
+ Insika::Command.build(:resume_task, { task_id: task_id }, transport: :recovery)
157
+ end
158
+
159
+ # Logging is pure observability: a logger failure must NEVER alter the
160
+ # recovery flow (otherwise a buggy logger would corrupt the summary —
161
+ # e.g. an id in resumed AND failed). We swallow any logger error.
162
+ def log(level, message)
163
+ @logger&.public_send(level, "[recovery] #{message}")
164
+ rescue StandardError
165
+ nil
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Insika
6
+ module Refinement
7
+ # ONE proposed change to an agent's instruction files. Data, not
8
+ # a diff of free text, and that is the load-bearing decision of the whole phase:
9
+ #
10
+ # · an ANCHORED edit is reviewable — the operator reads three lines, not a
11
+ # rewritten file, and can tell in seconds whether to approve it;
12
+ # · it is ATTRIBUTABLE — when the gate's score moves, it moved because of an
13
+ # edit you can point at, not because a model rewrote the prompt;
14
+ # · it is STALE-CHECKABLE — `before` must still match the file, so a proposal
15
+ # built from a snapshot cannot silently clobber an edit made since.
16
+ #
17
+ # A candidate arrives from anywhere (a model, the Studio, a JSON payload) and is
18
+ # always built through `Candidate.build`, which is the only place the bounds and
19
+ # the write allowlist are enforced. An edit that violates one is DROPPED with a
20
+ # reason rather than failing the whole candidate: a proposal with two good edits
21
+ # and one stale one is worth gating, and the operator should see what fell off.
22
+ Edit = Data.define(:file, :op, :anchor, :before, :after, :addresses) do
23
+ def replace? = op == "replace"
24
+
25
+ # -> the new content, or nil when this edit no longer applies to `content`.
26
+ # `append` puts the text at the END of the file: `anchor` is a LABEL for the
27
+ # reviewer ("## shipping_quote"), never a locator — the locator is `before`,
28
+ # and inventing a second one would give the same edit two ways to land.
29
+ def apply_to(content)
30
+ return nil if content.nil?
31
+ return "#{content.chomp}\n\n#{after}\n" unless replace?
32
+ return nil unless content.include?(before)
33
+
34
+ content.sub(before, after)
35
+ end
36
+
37
+ def to_h = { "file" => file, "op" => op, "anchor" => anchor, "before" => before,
38
+ "after" => after, "addresses" => addresses }
39
+ end
40
+
41
+ # A dropped edit and why. Kept ON the candidate rather than logged: "the model
42
+ # proposed four things and one was stale" is exactly what an operator reviewing
43
+ # the loop's usefulness needs, and asks them to judge precisely that.
44
+ Dropped = Data.define(:file, :op, :reason) do
45
+ def to_h = { "file" => file, "op" => op, "reason" => reason }
46
+ end
47
+
48
+ Candidate = Data.define(:id, :proposer, :rationale, :edits, :dropped) do
49
+ def empty? = edits.empty?
50
+ def files = edits.map(&:file).uniq
51
+
52
+ # name => new content, for the files this candidate touches. Edits to the same
53
+ # file compose in order, so two edits to TOOLS.md both land.
54
+ def apply(contents)
55
+ edits.each_with_object({}) do |edit, acc|
56
+ current = acc[edit.file] || contents[edit.file]
57
+ applied = edit.apply_to(current)
58
+ acc[edit.file] = applied unless applied.nil?
59
+ end
60
+ end
61
+
62
+ def to_h = { "id" => id, "proposer" => proposer, "rationale" => rationale,
63
+ "edits" => edits.map(&:to_h), "dropped" => dropped.map(&:to_h) }
64
+ end
65
+
66
+ # The bounds, all config. Defaults are deliberately small: what makes a
67
+ # diff reviewable is that it is short, and what keeps the gate's signal readable
68
+ # is that a run changed few things.
69
+ DEFAULT_LIMITS = { "max_edits" => 3, "max_bytes" => 1200, "max_total_growth" => 0.15 }.freeze
70
+
71
+ OPS = %w[replace append].freeze
72
+
73
+ module CandidateBuilder
74
+ module_function
75
+
76
+ # raw: { "proposer" =>, "rationale" =>, "edits" => [ … ] } (string keys)
77
+ # allowlist: the agent's `refinement.files`. EMPTY MEANS NOTHING IS WRITABLE —
78
+ # report-only, so every edit drops. Not "no restriction":
79
+ # an unset allowlist that meant "anything" would turn a missing
80
+ # config into the most permissive setting there is.
81
+ # contents: name => current content, for staleness and growth.
82
+ # -> Candidate (possibly empty; `empty?` never reaches the gate).
83
+ def build(raw, allowlist:, contents:, limits: {}, id: nil)
84
+ raw = Coercion.deep_stringify(raw.is_a?(Hash) ? raw : {})
85
+ bounds = DEFAULT_LIMITS.merge(limits.is_a?(Hash) ? Coercion.deep_stringify(limits) : {})
86
+ allow = Array(allowlist).map(&:to_s)
87
+
88
+ kept = []
89
+ dropped = []
90
+ # Growth is measured per FILE across the whole candidate, so three edits that
91
+ # are each under the cap cannot add up to a rewritten file.
92
+ grown = Hash.new(0)
93
+
94
+ Array(raw["edits"]).each do |edit|
95
+ built, reason = validate(Coercion.deep_stringify(edit), allow, contents, bounds, grown, kept.size)
96
+ if built
97
+ kept << built
98
+ grown[built.file] += built.after.to_s.bytesize - (built.replace? ? built.before.to_s.bytesize : 0)
99
+ else
100
+ dropped << Dropped.new(file: edit_field(edit, "file"), op: edit_field(edit, "op"), reason: reason)
101
+ end
102
+ end
103
+
104
+ Candidate.new(id: (id || SecureRandom.uuid).to_s,
105
+ proposer: Coercion.presence(raw["proposer"]) || "operator",
106
+ rationale: raw["rationale"].to_s, edits: kept, dropped: dropped)
107
+ end
108
+
109
+ # -> [Edit, nil] | [nil, reason]. One reason per edit, in the order an operator
110
+ # would ask: is it allowed, is it well-formed, does it still apply, is it small.
111
+ def validate(raw, allow, contents, bounds, grown, kept_count)
112
+ file = raw["file"].to_s
113
+ op = raw["op"].to_s
114
+ after = raw["after"].to_s
115
+
116
+ return [nil, "candidate is over max_edits (#{bounds['max_edits']})"] if kept_count >= bounds["max_edits"].to_i
117
+ return [nil, "'#{file}' is not on the refinement allowlist"] unless allow.include?(file)
118
+ return [nil, "unknown op '#{op}' (#{OPS.join('|')})"] unless OPS.include?(op)
119
+
120
+ current = contents[file]
121
+ return [nil, "'#{file}' does not exist for this agent"] if current.nil?
122
+ return [nil, "'after' is empty"] if after.strip.empty?
123
+
124
+ if after.bytesize > bounds["max_bytes"].to_i
125
+ return [nil, "edit is #{after.bytesize}B, over max_bytes (#{bounds['max_bytes']})"]
126
+ end
127
+
128
+ edit = Edit.new(file: file, op: op, anchor: Coercion.presence(raw["anchor"]),
129
+ before: raw["before"].to_s, after: after,
130
+ addresses: Array(raw["addresses"]).map(&:to_s))
131
+
132
+ if edit.replace?
133
+ return [nil, "'before' is empty — a replace with no anchor text is a rewrite"] if edit.before.empty?
134
+ # STALE: the file changed since the proposal was built (or the model
135
+ # hallucinated the text it claims to be replacing). Either way the edit
136
+ # describes a file that does not exist, and applying it by fuzzy match is
137
+ # how a refinement loop silently clobbers a human's edit.
138
+ return [nil, "'before' no longer matches '#{file}' (stale or invented)"] unless current.include?(edit.before)
139
+
140
+ if current.scan(edit.before).length > 1
141
+ return [nil, "'before' matches '#{file}' in #{current.scan(edit.before).length} places — ambiguous"]
142
+ end
143
+ end
144
+
145
+ growth = grown[file] + after.bytesize - (edit.replace? ? edit.before.bytesize : 0)
146
+ cap = (current.bytesize * bounds["max_total_growth"].to_f).ceil
147
+ return [nil, "would grow '#{file}' by #{growth}B, over max_total_growth (#{cap}B)"] if growth > cap
148
+
149
+ [edit, nil]
150
+ end
151
+
152
+ def edit_field(edit, key)
153
+ return nil unless edit.is_a?(Hash)
154
+
155
+ (edit[key] || edit[key.to_sym]).to_s
156
+ end
157
+ end
158
+ end
159
+ end