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,220 @@
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
+ # Eagerness is NOT here. It used to be a frontmatter flag, i.e. a property of the
15
+ # SKILL — but skills are shared between agents, so one flag forced one decision
16
+ # onto every allowlist holding the skill. It is a property of the AGENT
17
+ # (`profile.skills_eager`, see #eager_for).
18
+ #
19
+ # companions: names of the skills this one cannot work without. Injecting or
20
+ # loading a skill brings them along, so the half-recipe state cannot be assembled —
21
+ # a reference table arriving without the procedure that reads it is worse than
22
+ # nothing, because the model then never asks for the other half.
23
+ Skill = Data.define(:name, :description, :path, :body, :triggers, :companions)
24
+
25
+ # roots ordered by PRECEDENCE (highest first): workspace, managed,
26
+ # bundled. Same name in more than one root: the first wins.
27
+ #
28
+ # store (optional): a SkillStore with the skills AUTHORED in the Studio.
29
+ # They overlay the on-disk ones (seed) — the Store wins, it is the source of truth.
30
+ # Nil = disk-only behavior, zero regression.
31
+ def initialize(roots, store: nil)
32
+ @roots = Array(roots)
33
+ @store = store
34
+ @skills, @agent_skills = load_all
35
+ end
36
+
37
+ # `agent` (an agent id) resolves the AGENT SCOPE first, then the shared one — the
38
+ # same precedence chain the catalog already runs for store-over-disk and
39
+ # workspace-over-managed-over-bundled, with one more dimension.
40
+ #
41
+ # Three cases fall out of that one rule: SHARED (only the shared record exists),
42
+ # OVERRIDE (both exist, the agent's wins) and AGENT-PRIVATE (only the agent record
43
+ # exists — invisible elsewhere, and its name may collide freely).
44
+ #
45
+ # Without `agent` the shared scope is all there is, which is what every caller
46
+ # that has no agent in hand (the Studio's shared editor, a bare catalog) means.
47
+ def all(agent: nil)
48
+ shared = @skills
49
+ overrides = agent_scope(agent)
50
+ return shared.values if overrides.empty?
51
+
52
+ shared.merge(overrides).values
53
+ end
54
+
55
+ def find(name, agent: nil)
56
+ agent_scope(agent)[name.to_s] || @skills[name.to_s]
57
+ end
58
+
59
+ # Reloads from disk + Store and SWAPS the index atomically: an
60
+ # authored/edited skill takes effect without a restart. A turn in progress
61
+ # captured @skills at dispatch, so it does not see the swap mid-flight.
62
+ def reload
63
+ @skills, @agent_skills = load_all
64
+ self
65
+ end
66
+
67
+ # Per-agent allowlist: nil -> all | [] -> none | [names] -> subset. `agent`
68
+ # selects WHICH body each allowed name resolves to (see #find); the allowlist is
69
+ # by NAME either way, so specializing a skill never touches the allowlist.
70
+ def effective(skills_policy, agent: nil)
71
+ Allowlist.filter(all(agent: agent), skills_policy) { |s| s.name }
72
+ end
73
+
74
+ # THE single definition of "always in the prompt", consulted by all three
75
+ # surfaces that must agree: the body provider (injects these), the level-1
76
+ # catalog (hides them) and load_skill (refuses them). Split the rule across three
77
+ # files and they drift — which is the failure this whole feature came from.
78
+ #
79
+ # `profile.skills_eager` — a PER-AGENT decision, so a shared skill stays shared:
80
+ # nil | false -> none (progressive disclosure; the default)
81
+ # true -> every allowed skill (blanket; only for a corpus that fits the budget)
82
+ # [names] -> exactly these
83
+ #
84
+ # Deliberately NOT `Allowlist.filter`: there nil means ALL, which is the safe
85
+ # default for `skills`/`tools_allow` where nil is "no policy". Here nil must mean
86
+ # NONE — an unconfigured agent waking up with every skill body on every turn is
87
+ # the opposite of a safe default. A name that is not in the agent's `skills`
88
+ # allowlist is a silent no-op here (the intersection with `effective`); `doctor`
89
+ # flags it, because the operator who wrote the name meant it.
90
+ def eager_for(profile)
91
+ allowed = effective(profile.skills, agent: profile.id)
92
+ spec = profile.skills_eager
93
+ return allowed if blanket?(spec)
94
+ return [] if spec.nil? || spec == false
95
+
96
+ names = Array(spec).map { |n| n.to_s.strip }
97
+ allowed.select { |s| names.include?(s.name) }
98
+ end
99
+
100
+ # The complement: what the model still has to ASK for — and therefore what the
101
+ # level-1 list advertises and load_skill will serve.
102
+ def lazy_for(profile) = effective(profile.skills, agent: profile.id) - eager_for(profile)
103
+
104
+ # Level 1: compact list injected into the system prompt. Metadata only.
105
+ # Receives the set already filtered by the agent.
106
+ #
107
+ # `when=` carries the skill's `triggers:` — THE ROUTING TABLE, GENERATED. What
108
+ # actually made activation reliable on the pilot was a hand-written companion file
109
+ # listing each skill with its trigger phrases, and nothing checked it against the
110
+ # catalog: a skill created at 11:28 was invisible to a table written the day
111
+ # before, and the model obeyed the table. Rendering the same information from the
112
+ # catalog means it cannot disagree with the allowlist — a newly allowed skill
113
+ # appears the moment it is allowed. Detecting that drift would have been strictly
114
+ # worse than removing its source.
115
+ def format_for_prompt(skills = all)
116
+ return "" if skills.empty?
117
+
118
+ entries = skills.map do |s|
119
+ when_attr = Array(s.triggers).empty? ? "" : %( when="#{Array(s.triggers).join('; ')}")
120
+ %( <skill name="#{s.name}"#{when_attr}>#{s.description}</skill>)
121
+ end.join("\n")
122
+
123
+ <<~PROMPT.strip
124
+ <available_skills>
125
+ #{entries}
126
+ </available_skills>
127
+
128
+ Before ANY reply or tool call: scan the skills above. If one matches
129
+ or is even partially relevant to the task, you MUST call
130
+ `load_skill("name")` FIRST and follow what it returns. Err on the
131
+ side of loading. Only skip when genuinely none apply.
132
+ PROMPT
133
+ end
134
+
135
+ private
136
+
137
+ # The blanket switch, tolerant of the strings a form / JSON round-trip produces
138
+ # ("1" from a checkbox, "true" from a pack) — same reading as
139
+ # AgentProfile#stream_public?. Anything else (a list, nil, false) is not blanket.
140
+ def blanket?(spec) = Coercion.truthy?(spec)
141
+
142
+ # An agent's override index; {} for a nil agent or one that specialized nothing.
143
+ def agent_scope(agent) = agent.nil? ? {} : (@agent_skills[agent.to_s] || {})
144
+
145
+ # -> [shared index, { agent_id => index }].
146
+ def load_all
147
+ found = {}
148
+ @roots.each do |root|
149
+ Dir.glob(File.join(root, "**", "SKILL.md")).sort.each do |file|
150
+ skill = parse_content(File.read(file, encoding: "UTF-8"), path: file)
151
+ next unless skill
152
+
153
+ found[skill.name] ||= skill # precedence: first root wins
154
+ end
155
+ end
156
+ overlay_store(found)
157
+ [found, load_agent_scopes]
158
+ end
159
+
160
+ # Store skills overlay the on-disk ones (authored > seed). Sentinel path
161
+ # "store:<name>" — not a real file (load_skill uses `body`, not the path).
162
+ def overlay_store(found)
163
+ return unless @store
164
+
165
+ @store.all.each do |name, content|
166
+ skill = parse_content(content.to_s, path: "store:#{name}", key: name)
167
+ found[name.to_s] = skill if skill # Store wins
168
+ end
169
+ end
170
+
171
+ # Per-agent overrides / private skills, one index per agent. A store that predates
172
+ # the agent dimension answers nothing here, so this is {} and every lookup falls
173
+ # straight through to the shared scope.
174
+ def load_agent_scopes
175
+ return {} unless @store.respond_to?(:agents)
176
+
177
+ @store.agents.each_with_object({}) do |agent, acc|
178
+ index = {}
179
+ @store.all(agent: agent).each do |name, content|
180
+ skill = parse_content(content.to_s, path: "store:#{agent}/#{name}", key: name)
181
+ index[name.to_s] = skill if skill
182
+ end
183
+ acc[agent.to_s] = index unless index.empty?
184
+ end
185
+ end
186
+
187
+ # `key` = THE STORE POSITION, and it wins over the frontmatter `name:`. An override
188
+ # authored for one agent still says `name: escalation-to-human` inside — that is
189
+ # deliberate, it is the same skill specialized — and indexing by the parsed name
190
+ # would clobber the shared record globally, which is the exact bug the agent scope
191
+ # exists to fix. It also makes a pack whose directory name and frontmatter name
192
+ # disagree resolvable: the allowlist is written from the directory.
193
+ def parse_content(raw, path:, key: nil)
194
+ match = raw.match(/\A---\s*\n(.*?)\n---\s*\n(.*)\z/m)
195
+ return nil unless match
196
+
197
+ # Tolerant frontmatter: real packs have `: ` in the description prose, which
198
+ # strict YAML rejected (the pack would not load).
199
+ meta = Insika::Frontmatter.parse(match[1])
200
+ name = key || meta["name"]
201
+ return nil unless name && Coercion.present?(meta["name"])
202
+
203
+ Skill.new(
204
+ name: name.to_s,
205
+ description: meta["description"].to_s,
206
+ path: path,
207
+ body: match[2].strip,
208
+ triggers: parse_list(meta["triggers"]),
209
+ companions: parse_list(meta["companions"])
210
+ )
211
+ end
212
+
213
+ # `triggers:` / `companions:` frontmatter. YAML list, or comma-separated string
214
+ # under the lenient parse (which yields the whole value as one String).
215
+ def parse_list(raw)
216
+ list = raw.is_a?(String) ? raw.split(",") : Array(raw)
217
+ list.map { |t| t.to_s.strip }.reject(&:empty?)
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # AUTHORED skills, in two scopes.
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
+ # SHARED scope (`agent:` omitted) — one record per skill in the ConfigStore
12
+ # (scope "skills"), keyed by the skill name:
13
+ # { "content" => "<entire SKILL.md>",
14
+ # "updated_at" => iso8601,
15
+ # "history" => [ { "content" =>, "at" => }, ... ] }
16
+ #
17
+ # AGENT scope (`agent:` given) — one record per AGENT (scope "agent_skills"), the
18
+ # skills nested under it, exactly the AgentFileStore shape:
19
+ # { "skills" => { "<name>" => { "content" =>, "updated_at" =>, "history" => [] } } }
20
+ #
21
+ # The agent dimension is a SECOND ARGUMENT, never part of the key. A composite
22
+ # `"agent/name"` key would put a `/` inside what the Studio serves as a single path
23
+ # segment (`GET /skills/:name`, the editor, the versions list) — the class of route
24
+ # bug that ships green and 404s in production. Two arguments become two route
25
+ # segments (`/agents/:id/skills/:name`) and nothing needs encoding.
26
+ #
27
+ # Two scopes and not one: the shared records are untouched by the arrival of the
28
+ # agent dimension, so there is no migration and a live deployment keeps serving
29
+ # exactly what it served.
30
+ #
31
+ # THE STORE POSITION IS THE IDENTITY. Which scope a record sits in — and under which
32
+ # key — is what decides which skill it is; the frontmatter `name:` inside an override
33
+ # stays the bare shared name. See SkillCatalog#find.
34
+ class SkillStore
35
+ SCOPE = "skills"
36
+ AGENT_SCOPE = "agent_skills"
37
+ HISTORY_MAX = 20
38
+
39
+ def initialize(config_store:)
40
+ @cs = config_store
41
+ end
42
+
43
+ # -> String | nil (complete SKILL.md).
44
+ def get(name, agent: nil)
45
+ record(name, agent)&.fetch("content", nil)
46
+ end
47
+
48
+ # -> [String] names in the scope, lexicographic order.
49
+ def names(agent: nil)
50
+ agent.nil? ? @cs.keys(SCOPE) : agent_skills(agent).keys.sort
51
+ end
52
+
53
+ # -> { name => content } of the scope's authored skills.
54
+ def all(agent: nil)
55
+ names(agent: agent).each_with_object({}) { |n, acc| acc[n] = get(n, agent: agent) }
56
+ end
57
+
58
+ # -> [String] every agent that has specialized at least one skill. What the
59
+ # catalog overlays and `doctor` sweeps.
60
+ def agents = @cs.keys(AGENT_SCOPE).sort
61
+
62
+ # Writes (upsert). create_only refuses to overwrite. -> Hash (the stored record).
63
+ def write(name, content, agent: nil, create_only: false)
64
+ key = name.to_s
65
+ current = record(key, agent)
66
+ raise Insika::ValidationError, "skill '#{key}' already exists#{" for agent '#{agent}'" if agent}" if create_only && current
67
+
68
+ rec = build_record(content.to_s, current)
69
+ put(key, rec, agent)
70
+ rec
71
+ end
72
+
73
+ # -> bool (did it exist?).
74
+ def delete(name, agent: nil)
75
+ key = name.to_s
76
+ return @cs.delete(SCOPE, key) if agent.nil?
77
+
78
+ wrapper = @cs.get(AGENT_SCOPE, agent.to_s)
79
+ return false unless wrapper&.dig("skills", key)
80
+
81
+ wrapper["skills"].delete(key)
82
+ @cs.put(AGENT_SCOPE, agent.to_s, wrapper)
83
+ true
84
+ end
85
+
86
+ # -> [ { "content" =>, "at" => } ] most recent first.
87
+ def versions(name, agent: nil) = record(name, agent)&.fetch("history", []) || []
88
+
89
+ # Restores version `index` as the current content (a new write). -> Hash.
90
+ def restore(name, index, agent: nil)
91
+ hist = versions(name, agent: agent)
92
+ i = Integer(index)
93
+ raise Insika::NotFoundError, "skill '#{name}' not found" unless record(name.to_s, agent)
94
+ raise Insika::ValidationError, "version #{index} does not exist" if i.negative? || i >= hist.length
95
+
96
+ write(name, hist[i]["content"], agent: agent)
97
+ end
98
+
99
+ private
100
+
101
+ def record(name, agent)
102
+ return @cs.get(SCOPE, name.to_s) if agent.nil?
103
+
104
+ agent_skills(agent)[name.to_s]
105
+ end
106
+
107
+ def put(key, rec, agent)
108
+ return @cs.put(SCOPE, key, rec) if agent.nil?
109
+
110
+ wrapper = @cs.get(AGENT_SCOPE, agent.to_s) || { "skills" => {} }
111
+ wrapper["skills"] ||= {}
112
+ wrapper["skills"][key] = rec
113
+ @cs.put(AGENT_SCOPE, agent.to_s, wrapper)
114
+ end
115
+
116
+ def agent_skills(agent) = (@cs.get(AGENT_SCOPE, agent.to_s) || {})["skills"] || {}
117
+
118
+ def build_record(content, current)
119
+ history = current ? current.fetch("history", []) : []
120
+ if current
121
+ history = [{ "content" => current["content"], "at" => current["updated_at"] }] + history
122
+ history = history.first(HISTORY_MAX)
123
+ end
124
+ { "content" => content, "updated_at" => Time.now.utc.iso8601, "history" => history }
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # WHERE a message that arrived mid-run is allowed to enter the
5
+ # conversation.
6
+ #
7
+ # A customer who corrects themselves while the agent is calling tools ("1234567",
8
+ # three seconds after "queria saber do pedido") should have that land before the
9
+ # model's next reasoning step, not after the whole run. RubyLLM runs the entire tool
10
+ # loop inside `chat.ask`, so the only place to append is from inside its callbacks —
11
+ # which is enough, because they are public and additive:
12
+ #
13
+ # complete_once
14
+ # ├─ provider_completion → assistant message announcing N tool_calls
15
+ # ├─ after_message(assistant) ← N is read here
16
+ # └─ handle_tool_calls
17
+ # ├─ add_tool_result_message ×N
18
+ # │ └─ after_message(tool) ← counted; the Nth is THE BOUNDARY
19
+ # └─ halt_result || complete ← the next model step sees what we appended
20
+ #
21
+ # Counting to N is not an optimization, it is the correctness condition. A `user`
22
+ # message inserted BETWEEN tool results is rejected outright by Anthropic (all tool
23
+ # results of a batch must sit together) and merely tolerated by OpenAI.
24
+ #
25
+ # Two invariants this object exists to keep:
26
+ #
27
+ # · **Tail-append only.** Nothing already sent to the provider is edited, reordered
28
+ # or removed. That is what keeps the prompt cache valid, and a cache miss on a
29
+ # ~48k-token identity is a real cost, not a theoretical one.
30
+ # · **A halted batch injects nothing.** With `halt_when` there is no next model step
31
+ # (`handle_tool_calls` returns the Halt), so an appended message would sit in the
32
+ # transcript unanswered forever. The messages stay in the mailbox and the Executor
33
+ # releases them as a follow-up turn.
34
+ class SteerInjector
35
+ # chat: the turn's RubyLLM::Chat (already assembled).
36
+ # actor: the turn's TaskActor — the mailbox the steered messages arrive in.
37
+ # policy: the turn's QueuePolicy (`frame` decides how the text is worded).
38
+ # emit: ->(type, data) — the Executor's emitter, already bound to the task.
39
+ def initialize(chat:, actor:, policy:, emit:)
40
+ @chat = chat
41
+ @actor = actor
42
+ @policy = policy
43
+ @emit = emit
44
+ @expected = nil # tool calls announced by the batch in flight (nil = not in one)
45
+ @seen = 0
46
+ @halted = false
47
+ @injected = 0
48
+ end
49
+
50
+ # How many messages this run absorbed (read by specs and by the turn's event).
51
+ attr_reader :injected
52
+
53
+ # RubyLLM `after_tool_result`, with the RAW result — the only place a `Tool::Halt`
54
+ # is still recognizable. By the time it becomes a `role: tool` message its content
55
+ # is the payload, indistinguishable from an ordinary result.
56
+ def tool_result(result)
57
+ @halted = true if halt?(result)
58
+ end
59
+
60
+ # RubyLLM `after_message`. An assistant message carrying tool calls OPENS a batch;
61
+ # the Nth tool result CLOSES it, and that is the one boundary where appending is
62
+ # valid.
63
+ def message_ended(message)
64
+ role = field(message, :role).to_s
65
+ return open_batch(message) if role == "assistant"
66
+ return unless role == "tool" && @expected
67
+
68
+ @seen += 1
69
+ inject! if @seen >= @expected
70
+ end
71
+
72
+ private
73
+
74
+ def open_batch(message)
75
+ calls = field(message, :tool_calls)
76
+ size = calls.respond_to?(:size) ? calls.size : 0
77
+ # A message with no tool call is the model talking, not a batch: leave any
78
+ # pending message where it is (the turn is about to end, and the Executor
79
+ # releases it as a follow-up turn rather than answering it half-way).
80
+ return @expected = nil if size.zero?
81
+
82
+ @expected = size
83
+ @seen = 0
84
+ @halted = false
85
+ end
86
+
87
+ def inject!
88
+ @expected = nil
89
+ return if @halted # nothing will read it: leave it in the mailbox
90
+
91
+ texts = @actor.take_user_messages!
92
+ return if texts.empty?
93
+
94
+ texts.each { |text| @chat.add_message(role: :user, content: @policy.frame(text)) }
95
+ @injected += texts.size
96
+ # Counts only, never content — the text is already in the transcript, which is the
97
+ # surface that is allowed to carry it. `task_id`/`session_id` are the event's meta.
98
+ @emit.call(:turn_steered, { count: texts.size, total: @injected })
99
+ end
100
+
101
+ def halt?(result) = defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
102
+
103
+ def field(message, name)
104
+ return message.public_send(name) if message.respond_to?(name)
105
+ return message[name] || message[name.to_s] if message.respond_to?(:[])
106
+
107
+ nil
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Minimal persistence contract.
5
+ # Namespace-scoped KV, transactional when the backend supports it.
6
+ # Every implementation passes the SAME contract suite
7
+ # (lib/insika/testing/store_contract.rb — requirable from outside the repo,
8
+ # Values must be JSON-serializable.
9
+ #
10
+ # scope: String — separates domains/tenants (e.g. "sessions", "tasks:tenant_x")
11
+ # key: Hierarchical String (e.g. "task:123", "checkpoint:123:turn:4")
12
+ #
13
+ # Contract rules (verified by the suite):
14
+ # - get on a nonexistent key -> nil (never an exception)
15
+ # - set overwrites silently (last-write-wins)
16
+ # - round-trip preserves JSON types; Symbols become Strings (the domain
17
+ # normalizes at the boundary)
18
+ # - list(scope) returns only keys of the scope, ordered lexicographically;
19
+ # prefix filters by start_with?
20
+ # - a nested transaction reuses the outer transaction (no SAVEPOINT)
21
+ # - a serialization failure on write -> Insika::StoreError (fail-fast)
22
+ #
23
+ # Backends `include Store` and override the five methods; any forgotten
24
+ # method raises NotImplementedError (fail-fast, better than a distant
25
+ # NoMethodError).
26
+ module Store
27
+ # -> Object | nil (deserialized)
28
+ def get(scope, key)
29
+ raise NotImplementedError, "#{self.class}#get"
30
+ end
31
+
32
+ # -> value (the SAME object passed in, not the round-trip)
33
+ def set(scope, key, value)
34
+ raise NotImplementedError, "#{self.class}#set"
35
+ end
36
+
37
+ # -> true | false (did it exist?)
38
+ def delete(scope, key)
39
+ raise NotImplementedError, "#{self.class}#delete"
40
+ end
41
+
42
+ # -> [String] keys ordered lexicographically
43
+ def list(scope, prefix = nil)
44
+ raise NotImplementedError, "#{self.class}#list"
45
+ end
46
+
47
+ # -> the block's result; atomic if the backend supports it
48
+ def transaction(&blk)
49
+ raise NotImplementedError, "#{self.class}#transaction"
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ module Stores
7
+ # In-memory backend for dev/test.
8
+ # Serializes JSON even in memory: exact parity of type semantics
9
+ # with SQLite — the contract suite is honest.
10
+ # No lock: cooperative fibers do not preempt in the middle of a Hash
11
+ # operation.
12
+ class Memory
13
+ include Store
14
+
15
+ def initialize
16
+ @data = new_store
17
+ @tx_depth = 0
18
+ @snapshot = nil
19
+ end
20
+
21
+ def get(scope, key)
22
+ raw = @data[scope][key]
23
+ return nil if raw.nil?
24
+
25
+ JSON.parse(raw)
26
+ end
27
+
28
+ def set(scope, key, value)
29
+ @data[scope][key] = serialize(value)
30
+ value
31
+ end
32
+
33
+ def delete(scope, key)
34
+ !@data[scope].delete(key).nil?
35
+ end
36
+
37
+ def list(scope, prefix = nil)
38
+ keys = @data[scope].keys.sort
39
+ prefix ? keys.select { |k| k.start_with?(prefix) } : keys
40
+ end
41
+
42
+ # Snapshot at the start of the outermost transaction; an exception at any
43
+ # level -> restore the snapshot and re-propagate (a REAL rollback).
44
+ # A nested one reuses the outer (no SAVEPOINT).
45
+ def transaction
46
+ if @tx_depth.positive?
47
+ @tx_depth += 1
48
+ begin
49
+ return yield
50
+ ensure
51
+ @tx_depth -= 1
52
+ end
53
+ end
54
+
55
+ @snapshot = deep_snapshot
56
+ @tx_depth = 1
57
+ begin
58
+ yield
59
+ rescue StandardError
60
+ restore_snapshot
61
+ raise
62
+ ensure
63
+ @tx_depth = 0
64
+ @snapshot = nil
65
+ end
66
+ end
67
+
68
+ # JSON model types + Symbol (coerced to String on write).
69
+ # Any other type is "garbage" and must be rejected.
70
+ JSONABLE = [NilClass, TrueClass, FalseClass, Integer, Float,
71
+ String, Symbol].freeze
72
+ private_constant :JSONABLE
73
+
74
+ private
75
+
76
+ def new_store
77
+ Hash.new { |h, scope| h[scope] = {} }
78
+ end
79
+
80
+ # Enforces the contract's type model at the boundary:
81
+ # Symbol/symbol-key become String; a type outside the JSON model ->
82
+ # StoreError on WRITE (fail-fast; never writes garbage).
83
+ #
84
+ # Does not use `JSON.generate(strict: true)`: under json 2.7.1 (the pinned
85
+ # version) `strict` rejects Symbol, which would violate the Symbol coercion.
86
+ # The explicit validation is independent of the json version and gives the
87
+ # SAME semantics as SQLite.
88
+ # The transaction block's exception (from the caller) propagates without
89
+ # wrapping; only a backend error becomes StoreError.
90
+ def serialize(value)
91
+ ensure_jsonable!(value)
92
+ JSON.generate(value)
93
+ rescue JSON::GeneratorError => e
94
+ raise Insika::StoreError, "value not serializable: #{e.message}"
95
+ end
96
+
97
+ def ensure_jsonable!(value)
98
+ case value
99
+ when *JSONABLE then nil
100
+ when Array then value.each { |v| ensure_jsonable!(v) }
101
+ when Hash then value.each { |k, v| ensure_jsonable!(k); ensure_jsonable!(v) }
102
+ else
103
+ raise Insika::StoreError,
104
+ "value not serializable: #{value.class} not allowed in JSON"
105
+ end
106
+ end
107
+
108
+ # Deep dup: the values are already JSON strings (immutable in practice),
109
+ # so a per-scope dup is enough — there is no nested mutable structure.
110
+ def deep_snapshot
111
+ @data.each_with_object({}) { |(scope, kv), acc| acc[scope] = kv.dup }
112
+ end
113
+
114
+ # Recreates the Hash with the default proc (otherwise @data[scope] on a
115
+ # new scope after rollback would raise) and restores only the snapshot's
116
+ # scopes — scopes created inside the transaction disappear.
117
+ def restore_snapshot
118
+ @data = new_store
119
+ @snapshot.each { |scope, kv| @data[scope] = kv }
120
+ end
121
+ end
122
+ end
123
+ end