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,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ module Providers
6
+ # Reads the Session Store. The ONLY history provider: the three transcript
7
+ # sources converge here — the Executor does not pick a source. Produces
8
+ # :history fragments (1 per message), with priority scaled by recency
9
+ # with a CEILING of 79: the budget cut discards the oldest ones first
10
+ # and history NEVER outranks skills (80) or identity (100).
11
+ class Session < ContextProvider
12
+ def initialize(session_store:)
13
+ @session_store = session_store
14
+ end
15
+
16
+ def call(request)
17
+ messages = transcript_for(request)
18
+ return [] if messages.nil? || messages.empty?
19
+
20
+ # A3/C3 opt-in: identical tool results in the transcript collapse to a
21
+ # back-reference (the cheap half of compaction). CHANGES WHAT THE MODEL
22
+ # SEES — hence the profile flag, never a default. Applied BEFORE the
23
+ # eviction-unit grouping so a cycle's results are already slim.
24
+ messages = compress_history(messages, request)
25
+
26
+ # 1 fragment per EVICTION UNIT (R1): a plain message, OR an
27
+ # assistant-with-tool_calls together with its tool results. Grouping at
28
+ # the fragment level means the budget cut (apply_budget) drops a whole
29
+ # tool cycle atomically — a tool_use is NEVER seeded without its result
30
+ # (which providers reject), without touching apply_budget itself.
31
+ eviction_units(messages).each_with_index.map do |unit, idx|
32
+ ContextFragment.build(
33
+ # single message stays a Hash (compat with existing fragments); a
34
+ # multi-message cycle is an Array (seed_history flattens it back).
35
+ content: unit.size == 1 ? unit.first : unit,
36
+ placement: :history,
37
+ # HISTORY_MAX ceiling; idx 0 = oldest (drops first in the cut)
38
+ priority: [Context::Priority::HISTORY_BASE + idx, Context::Priority::HISTORY_MAX].min,
39
+ source: id
40
+ )
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ # Groups a flat message list into eviction units. An assistant message
47
+ # carrying tool_calls absorbs the `role: tool` messages that immediately
48
+ # follow it (its results). Everything else is a unit of one.
49
+ def eviction_units(messages)
50
+ normalized = messages.map { |m| normalize(m) }
51
+ units = []
52
+ i = 0
53
+ while i < normalized.length
54
+ msg = normalized[i]
55
+ i += 1
56
+ if msg[:role].to_s == "assistant" && tool_calls?(msg)
57
+ cycle = [msg]
58
+ while i < normalized.length && normalized[i][:role].to_s == "tool"
59
+ cycle << normalized[i]
60
+ i += 1
61
+ end
62
+ units << cycle
63
+ else
64
+ units << [msg]
65
+ end
66
+ end
67
+ units
68
+ end
69
+
70
+ # Symbol-keyed message preserving tool_calls / tool_call_id when present
71
+ # (a plain message keeps just {role, content} — parity with the old shape).
72
+ def normalize(msg)
73
+ h = { role: msg[:role] || msg["role"], content: msg[:content] || msg["content"] }
74
+ tool_calls = msg[:tool_calls] || msg["tool_calls"]
75
+ tool_call_id = msg[:tool_call_id] || msg["tool_call_id"]
76
+ h[:tool_calls] = tool_calls if tool_calls
77
+ h[:tool_call_id] = tool_call_id if tool_call_id
78
+ h
79
+ end
80
+
81
+ def tool_calls?(msg) = msg[:tool_calls] && !Array(msg[:tool_calls]).empty?
82
+
83
+ # The compression is opt-in per agent (profile data, config-over-code):
84
+ # absent/off -> the transcript passes through byte-identical (parity).
85
+ def compress_history(messages, request)
86
+ profile = request.respond_to?(:profile) ? request.profile : nil
87
+ return messages unless profile&.tool_output_compression
88
+
89
+ ToolOutputCompressor.compress_transcript(messages)
90
+ end
91
+
92
+ # Precedence: checkpoint -> explicit history -> store.
93
+ # The first present source wins; no merge.
94
+ def transcript_for(request)
95
+ return request.checkpoint.messages if request.checkpoint
96
+
97
+ explicit = explicit_history(request)
98
+ return explicit if explicit
99
+ return session_messages(request.session) if request.session
100
+
101
+ nil
102
+ end
103
+
104
+ # Source 2 (explicit history): the handler passes it in request.vars[:history].
105
+ # Isolated in a single method to change the convention with 1 line.
106
+ def explicit_history(request)
107
+ vars = request.vars.to_h
108
+ vars[:history] || vars["history"]
109
+ end
110
+
111
+ # CONDITIONAL requiredness: when a session is requested, a read
112
+ # failure becomes a ContextError (aborts the turn); the base required?
113
+ # does not receive the request, so the behavior lives here.
114
+ def session_messages(session)
115
+ @session_store.find(session.id)&.messages || []
116
+ rescue StandardError => e # read failure (exception/StoreError)
117
+ raise ContextError.new("Session provider failed with a requested session: #{e.message}",
118
+ provider: id)
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ module Providers
6
+ # Level 1 (progressive disclosure) of the profile's CANDIDATE skills.
7
+ # The Executor's LoadSkill does NOT come from here: it is built with
8
+ # resolution.allowed_skills (a policy decision). Context->Policy order: the
9
+ # provider produces the candidate; the policy cuts afterwards.
10
+ class Skill < CatalogProvider
11
+ # priority 80: above deferred tools (70), below pinned identity.
12
+ def priority = Context::Priority::SKILL
13
+
14
+ private
15
+
16
+ # Only the skills the model still has to ASK for. An eager skill is already in
17
+ # the prompt in full, so advertising it here would invite a `load_skill` call
18
+ # that buys a duplicate — and the catalog's whole job is to describe what is
19
+ # NOT yet loaded. Nothing lazy left -> CatalogProvider emits no fragment.
20
+ def entries(request) = @catalog.lazy_for(request.profile)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ module Providers
6
+ # Level-2 skill BODIES in the prompt, with two selection modes — both
7
+ # deterministic, neither asks the model:
8
+ #
9
+ # profile.skills_eager -> the agent's eager set (all, or a named list),
10
+ # every turn. No decision at all, so no miss rate.
11
+ # Costs the bodies' tokens.
12
+ # `triggers:` in the -> only the skills whose trigger matches this
13
+ # frontmatter message (whole word, accent- and case-insensitive).
14
+ #
15
+ # Model-invoked loading (load_skill) stays the fallback for everything else.
16
+ #
17
+ # A trigger only belongs on a skill that can COMPLETE the turn by itself.
18
+ # Injecting a reference table whose companion skill holds the procedure is
19
+ # worse than injecting nothing: the model has a plausible half-recipe in the
20
+ # prompt, so it never calls load_skill for the other half. Measured on a real
21
+ # pack — the line map arrived, the query-construction rules did not, and the
22
+ # searches came out malformed.
23
+ # Activation here is NOT a tool call, so nothing in the transcript would show
24
+ # it. The fragment's `labels` carry the name AND THE REASON, and the EXECUTOR
25
+ # announces them (:skill_activated) — not this provider. A provider only has the
26
+ # ContextRequest, which has no task, and the Studio's SSE drops an event whose
27
+ # meta lacks `task_id` when the subscriber is task-scoped (Subscription#matches?):
28
+ # emitting from here produced an event that was correct and never arrived.
29
+ class SkillTrigger < ContextProvider
30
+ def initialize(catalog:)
31
+ @catalog = catalog
32
+ end
33
+
34
+ def call(request)
35
+ matched = select(request)
36
+ return [] if matched.empty?
37
+
38
+ content = matched.map do |skill, _reason|
39
+ %(<active_skill name="#{skill.name}">\n#{skill.body}\n</active_skill>)
40
+ end.join("\n\n")
41
+
42
+ labels = matched.map { |skill, reason| { "name" => skill.name, "reason" => reason } }
43
+ [ContextFragment.build(content: content, placement: :system,
44
+ priority: Context::Priority::SKILL_BODY, source: id,
45
+ labels: labels)]
46
+ end
47
+
48
+ private
49
+
50
+ # -> [[skill, reason]]. Two independent reasons a body lands here: the AGENT
51
+ # marked it eager (always), or a `triggers:` entry matched THIS message.
52
+ # The union is injected; everything else stays at level 1 for load_skill, where
53
+ # the model's call is the only record of what it reached for.
54
+ #
55
+ # The reason travels with the skill from here to the activation card, because
56
+ # "which skills were active" without "why each one was" is the information the
57
+ # deterministic paths destroyed when they replaced the load_skill call.
58
+ def select(request)
59
+ eager = @catalog.eager_for(request.profile).map { |skill| [skill, "eager"] }
60
+ # `triggered` reads the LAZY set, which excludes the eager one — so a skill
61
+ # cannot arrive twice. uniq_by name anyway: the invariant is worth not
62
+ # depending on from here.
63
+ selected = (eager + triggered(request)).uniq { |skill, _reason| skill.name }
64
+ (selected + companions(selected, request)).uniq { |skill, _reason| skill.name }
65
+ end
66
+
67
+ # Declared `companions:` travel with whatever brought them, so the half-recipe
68
+ # state cannot be assembled: the line map that arrived by trigger takes its
69
+ # query-construction rules with it. ONE LEVEL, deliberately — a transitive walk
70
+ # would make a cycle a hang and a chain a budget blowout, and "cannot work
71
+ # without" is a direct relationship.
72
+ #
73
+ # Restricted to the agent's own allowed set: a companion the agent cannot see
74
+ # is not injectable, and `doctor` flags that declaration rather than the engine
75
+ # quietly widening the allowlist.
76
+ def companions(selected, request)
77
+ wanted = selected.flat_map { |skill, _reason| Array(skill.companions).map { |c| [c.to_s, skill.name] } }
78
+ return [] if wanted.empty?
79
+
80
+ by_name = @catalog.effective(request.profile.skills, agent: request.profile.id)
81
+ .each_with_object({}) { |s, acc| acc[s.name] = s }
82
+ wanted.filter_map do |name, of|
83
+ skill = by_name[name]
84
+ [skill, "companion:#{of}"] if skill
85
+ end
86
+ end
87
+
88
+ def triggered(request)
89
+ message = fold(request.message)
90
+ return [] if message.empty?
91
+
92
+ @catalog.lazy_for(request.profile).filter_map do |skill|
93
+ phrase = matched_trigger(skill, message)
94
+ [skill, "trigger:#{phrase}"] if phrase
95
+ end
96
+ end
97
+
98
+ # The trigger phrase that fired, or nil. AS AUTHORED, never as typed: the
99
+ # reason lands on the activation card, and what an operator needs there is the
100
+ # config line they can go and edit — not an echo of the customer's message
101
+ # (which also keeps the label content-free, like the rest of the trace).
102
+ #
103
+ # Two hygiene rules, and both are tokenization rather than the semantic
104
+ # matching this feature deliberately does not do:
105
+ #
106
+ # whole word — bare substring made `triggers: presente` fire inside
107
+ # *apresente*, and the card now PRINTS the matched phrase: `trigger:presente`
108
+ # on a turn about *apresentação* would discredit the card on day one.
109
+ #
110
+ # folded accents — the corpus is Portuguese and customers type *maquiagem*
111
+ # and *maquiágem* unpredictably, so both sides are folded before comparing.
112
+ def matched_trigger(skill, folded_message)
113
+ skill.triggers.find do |trigger|
114
+ needle = fold(trigger)
115
+ next false if needle.empty?
116
+
117
+ /(?<![[:alnum:]])#{Regexp.escape(needle)}(?![[:alnum:]])/.match?(folded_message)
118
+ end
119
+ end
120
+
121
+ # NFD splits an accented letter into letter + combining mark; dropping the
122
+ # marks (\p{Mn}) leaves the bare letter, so "maquiágem" and "maquiagem" fold
123
+ # to the same string.
124
+ def fold(text) = text.to_s.unicode_normalize(:nfd).gsub(/\p{Mn}/, "").downcase
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ module Providers
6
+ # Level 1 (progressive disclosure) of the profile's deferred TOOLS. The
7
+ # slice is `profile.tools_deferred`, known at stage 2 — it may be slightly
8
+ # over-inclusive (showing a deferred tool the Policy later denies); acceptable,
9
+ # the real cut happens at PROMOTION (tool_search, `deferred ∩ allowed`).
10
+ class ToolSearch < CatalogProvider
11
+ # priority 70: below skills (80) in the sacrifice order.
12
+ def priority = Context::Priority::TOOL_SEARCH
13
+
14
+ private
15
+
16
+ def entries(request) = @catalog.subset(request.profile.tools_deferred)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Per-session CONTEXT trace, for the Studio's breakdown-by-category card
5
+ # One record per session in the raw backend (scope
6
+ # "context_traces") — RUNTIME data, next to sessions/tasks, like the
7
+ # ToolTraceStore it mirrors. A capped LIST of entries, ONE PER TURN, written
8
+ # by the Executor after tool assembly.
9
+ #
10
+ # Unlike the tool trace there is NO security machinery here by construction:
11
+ # the entry is counts and provider ids only (category -> tokens/fragments/
12
+ # pinned, the tools estimate, the budget verdict) — never fragment content,
13
+ # so there is nothing to mask. `record` still rescues everything: the trace
14
+ # NEVER breaks the turn.
15
+ class ContextTraceStore
16
+ SCOPE = "context_traces"
17
+ MAX_PER_SESSION = 50 # one per turn; turns are the unit, not tool calls
18
+
19
+ def initialize(store:)
20
+ @store = store
21
+ end
22
+
23
+ # Writes an entry into the session — UPSERT by (task_id, turn): a turn that
24
+ # suspends (approval) and resumes re-runs the context stage, and the
25
+ # re-record replaces the first one instead of duplicating it. (`turn` is
26
+ # 1-based PER TASK, so the task is part of the key.) Missing session_id ->
27
+ # no-op.
28
+ def record(session_id:, entry:)
29
+ sid = session_id.to_s
30
+ return if sid.empty?
31
+
32
+ e = sanitize(entry)
33
+ key = [e["task_id"], e["turn"]]
34
+ list = (@store.get(SCOPE, sid) || []).reject { |x| [x["task_id"], x["turn"]] == key } + [e]
35
+ @store.set(SCOPE, sid, list.last(MAX_PER_SESSION))
36
+ rescue StandardError
37
+ nil
38
+ end
39
+
40
+ # -> [Hash] session entries in chronological order. [] if none.
41
+ def for_session(session_id) = @store.get(SCOPE, session_id.to_s) || []
42
+
43
+ # Discards a session's trace (cleanup). -> bool (did it exist?).
44
+ def clear(session_id) = @store.delete(SCOPE, session_id.to_s)
45
+
46
+ private
47
+
48
+ # Keeps only the known shape; coerces numbers and strings so a caller bug
49
+ # degrades the card instead of poisoning the record.
50
+ def sanitize(entry)
51
+ e = entry.is_a?(Hash) ? entry : {}
52
+ categories = e[:categories] || e["categories"] || {}
53
+ tools = e[:tools] || e["tools"] || {}
54
+ {
55
+ "task_id" => (e[:task_id] || e["task_id"]).to_s,
56
+ "turn" => int(e[:turn] || e["turn"]),
57
+ "at" => (e[:at] || e["at"]).to_s,
58
+ "cap" => int(e[:cap] || e["cap"]),
59
+ "used" => int(e[:used] || e["used"]),
60
+ "evicted" => Array(e[:evicted] || e["evicted"]).map(&:to_s),
61
+ "categories" => categories.each_with_object({}) do |(name, c), acc|
62
+ c = {} unless c.is_a?(Hash)
63
+ cat = { "tokens" => int(c[:tokens] || c["tokens"]),
64
+ "fragments" => int(c[:fragments] || c["fragments"]),
65
+ "pinned" => int(c[:pinned] || c["pinned"]) }
66
+ # WHAT the category carried and WHY ({name, reason}) — still ids only, so
67
+ # the no-masking-needed contract above holds. Omitted when empty: most
68
+ # categories have nothing to name and an empty key is just noise.
69
+ labels = normalize_labels(c[:labels] || c["labels"])
70
+ cat["labels"] = labels unless labels.empty?
71
+ acc[name.to_s] = cat
72
+ end,
73
+ "tools" => { "count" => int(tools[:count] || tools["count"]),
74
+ "tokens" => int(tools[:tokens] || tools["tokens"]) }
75
+ }
76
+ end
77
+
78
+ # Labels are {name, reason} in string keys (ContextFragment.label). A bare string
79
+ # still reads as a nameless-reason label: an entry recorded before reasons existed,
80
+ # or a caller that only has the id, degrades the card instead of poisoning it.
81
+ def normalize_labels(raw)
82
+ Array(raw).filter_map do |label|
83
+ name, reason = label.is_a?(Hash) ? [label[:name] || label["name"], label[:reason] || label["reason"]] : [label, nil]
84
+ next if name.to_s.empty?
85
+
86
+ { "name" => name.to_s, "reason" => reason&.to_s }.compact
87
+ end.uniq
88
+ end
89
+
90
+ def int(value) = Integer(value || 0)
91
+ end
92
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # Durable record of an ASYNC delegation (hermes
8
+ # "delegation durability"). The synchronous subagent needs no
9
+ # record — it lives and dies inside the parent's turn. The ASYNC subagent does:
10
+ # the parent DISPATCHES and its turn ends; the child runs independently; when the
11
+ # child finishes, its result is delivered to the parent as a NEW turn (never
12
+ # spliced mid-turn — preserves role alternation + prompt cache). This record is
13
+ # what survives a kill -9 so a completed child's result is never lost.
14
+ #
15
+ # Lifecycle (never backwards): dispatched -> completed -> delivered.
16
+ # dispatched the child was spawned; result not captured yet.
17
+ # completed the child is terminal; result/error captured durably.
18
+ # delivered the result was handed to the parent session (claimed — the
19
+ # claim is what makes delivery AT-MOST-ONCE across crashes).
20
+ #
21
+ # Normalizes symbol->string on WRITE (the backend only round-trips JSON types),
22
+ # like the other domain stores.
23
+ class DelegationStore
24
+ include Coercion
25
+
26
+ SCOPE = "delegations"
27
+ KEY_PREFIX = "delegation:"
28
+
29
+ STATUSES = %i[dispatched completed delivered].freeze
30
+
31
+ Delegation = Data.define(
32
+ :id, :parent_task_id, :parent_session_id, :parent_agent,
33
+ :child_agent, :child_task_id, :child_session_id, :depth,
34
+ :status, :result, :error, :created_at, :updated_at
35
+ )
36
+
37
+ def initialize(store:)
38
+ @store = store
39
+ end
40
+
41
+ # -> Delegation (:dispatched).
42
+ def create(parent_task_id:, parent_session_id:, parent_agent:, child_agent:,
43
+ child_task_id:, child_session_id:, depth:, id: SecureRandom.uuid)
44
+ record = {
45
+ "id" => id.to_s,
46
+ "parent_task_id" => parent_task_id.to_s,
47
+ "parent_session_id" => parent_session_id&.to_s,
48
+ "parent_agent" => parent_agent.to_s,
49
+ "child_agent" => child_agent.to_s,
50
+ "child_task_id" => child_task_id.to_s,
51
+ "child_session_id" => child_session_id.to_s,
52
+ "depth" => depth,
53
+ "status" => "dispatched",
54
+ "result" => nil,
55
+ "error" => nil,
56
+ "created_at" => timestamp,
57
+ "updated_at" => timestamp
58
+ }
59
+ @store.set(SCOPE, key_for(id), record)
60
+ to_delegation(record)
61
+ end
62
+
63
+ # -> Delegation | nil
64
+ def find(id)
65
+ record = @store.get(SCOPE, key_for(id))
66
+ record && to_delegation(record)
67
+ end
68
+
69
+ # -> Delegation | nil for a given child task (the terminal hook's lookup). O(n)
70
+ # scan — single-node, like PendingActionStore#open_for.
71
+ def find_by_child_task(child_task_id)
72
+ id = child_task_id.to_s
73
+ scan { |d| return d if d.child_task_id == id }
74
+ nil
75
+ end
76
+
77
+ # -> [Delegation] that are NOT delivered yet (boot recovery). completed-but-
78
+ # undelivered = a crash between capture and delivery; dispatched = the child
79
+ # may or may not be terminal (the caller checks the child task).
80
+ def undelivered
81
+ scan.reject { |d| d.status == :delivered }
82
+ end
83
+
84
+ # dispatched -> completed, capturing the child's result/error. Idempotent: a
85
+ # second call on an already-completed/delivered record is a no-op (returns the
86
+ # current record) — the terminal hook and recovery can race.
87
+ def mark_completed(id, result: nil, error: nil)
88
+ record = fetch!(id)
89
+ return to_delegation(record) unless record["status"] == "dispatched"
90
+
91
+ record["status"] = "completed"
92
+ record["result"] = result
93
+ record["error"] = error
94
+ touch(id, record)
95
+ end
96
+
97
+ # completed -> delivered, ATOMICALLY (the claim) — across processes, not just
98
+ # fibers: the read-check-write rides Store#transaction, so two workers racing
99
+ # the same record serialize on the backend's lock and only one sees
100
+ # :completed. Returns true only for the caller that won the transition — that
101
+ # caller (and only it) spawns the delivery turn, so delivery is at-most-once
102
+ # even if the hook and recovery both fire. A record not in :completed
103
+ # (already delivered, or still dispatched) -> false.
104
+ def claim_delivery(id)
105
+ @store.transaction do
106
+ record = @store.get(SCOPE, key_for(id))
107
+ next false unless record && record["status"] == "completed"
108
+
109
+ record["status"] = "delivered"
110
+ record["updated_at"] = timestamp
111
+ @store.set(SCOPE, key_for(id), record)
112
+ true
113
+ end
114
+ end
115
+
116
+ private
117
+
118
+ def scan
119
+ return enum_for(:scan) unless block_given?
120
+
121
+ @store.list(SCOPE, KEY_PREFIX).each do |key|
122
+ record = @store.get(SCOPE, key)
123
+ yield to_delegation(record) if record
124
+ end
125
+ end
126
+
127
+ def fetch!(id)
128
+ @store.get(SCOPE, key_for(id)) ||
129
+ (raise Insika::NotFoundError, "delegation not found: #{id}")
130
+ end
131
+
132
+ def touch(id, record)
133
+ record["updated_at"] = timestamp
134
+ @store.set(SCOPE, key_for(id), record)
135
+ to_delegation(record)
136
+ end
137
+
138
+ def key_for(id) = "#{KEY_PREFIX}#{id}"
139
+
140
+ def to_delegation(record)
141
+ Delegation.new(
142
+ id: record["id"], parent_task_id: record["parent_task_id"],
143
+ parent_session_id: record["parent_session_id"], parent_agent: record["parent_agent"],
144
+ child_agent: record["child_agent"], child_task_id: record["child_task_id"],
145
+ child_session_id: record["child_session_id"], depth: record["depth"],
146
+ status: record["status"].to_sym, result: record["result"], error: record["error"],
147
+ created_at: record["created_at"], updated_at: record["updated_at"]
148
+ )
149
+ end
150
+
151
+ def timestamp = Time.now.utc.iso8601
152
+ end
153
+ end