insika 0.1.0 → 0.3.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 (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "time"
5
+
6
+ module Insika
7
+ # C2 — one durable record per mirrored exchange (shadow mode),
8
+ # written by TWO INDEPENDENT HALVES: ours at the turn's terminal, the
9
+ # incumbent's at the mirror. Both land on the same key — a digest of
10
+ # (channel, external_id, event_id), deterministic and order-free — so the two
11
+ # writers converge without an index and without ordering assumptions.
12
+ #
13
+ # It stores and it counts; it does not judge, does not fold a verdict, does
14
+ # not know what the criterion says.
15
+ #
16
+ # Status transitions (never backwards):
17
+ #
18
+ # ┌─ record_incumbent ─┐
19
+ # (nothing) ────┤ ├─▶ open ─▶ complete ─▶ judged
20
+ # └─ record_ours ─────┘ └─▶ silent (never judged)
21
+ # └─▶ incomplete (expire)
22
+ class ShadowPairStore
23
+ SCOPE = "shadow_pairs"
24
+ KEY_PREFIX = "pair:"
25
+
26
+ STATUSES = %i[open complete silent judged incomplete].freeze
27
+
28
+ Pair = Data.define(
29
+ :id, :channel, :agent, :session_id, :task_id, :event_id,
30
+ :inbound, :incumbent_reply, :insika_reply,
31
+ :status, :verdict, :criterion_sha, :created_at, :updated_at
32
+ ) do
33
+ def complete? = %i[complete silent].include?(status)
34
+ def judged? = status == :judged
35
+ def outcome = verdict && verdict["outcome"]
36
+ def human_assisted? = verdict && verdict["vs"] == "human-assisted"
37
+ end
38
+
39
+ def initialize(store:)
40
+ @store = store
41
+ end
42
+
43
+ # The correlation key BOTH writers compute independently. SHA-256 hex of
44
+ # "<channel>\0<external_id>\0<event_id>", truncated to 32 — deterministic,
45
+ # order-free, and it keeps a phone number out of the store's key space.
46
+ def self.key_for(channel:, external_id:, event_id:)
47
+ Digest::SHA256.hexdigest("#{channel}\0#{external_id}\0#{event_id}")[0, 32]
48
+ end
49
+
50
+ # Our half. Upsert: creates the record or fills our fields on the incumbent's.
51
+ # `reply` may be "" — a turn that published nothing (halt_when, an out-of-band
52
+ # tool) is recorded as :silent rather than left invisible. -> Pair
53
+ def record_ours(id:, channel:, agent:, session_id:, task_id:, event_id:,
54
+ inbound:, reply:, criterion_sha:)
55
+ upsert(id) do |record, created|
56
+ record["channel"] = channel.to_s
57
+ record["event_id"] = event_id.to_s
58
+ record["agent"] = agent
59
+ record["session_id"] = session_id&.to_s
60
+ record["task_id"] = task_id&.to_s
61
+ record["inbound"] = inbound.to_s
62
+ record["insika_reply"] = reply.to_s
63
+ record["criterion_sha"] = criterion_sha
64
+ created
65
+ end
66
+ end
67
+
68
+ # The incumbent's half (the mirror contract). Same upsert shape; the fields
69
+ # this half owns are the reply and, on first write, the timestamp the mirror
70
+ # reports. Never overwrites our half's fields. First-write-wins is enforced
71
+ # HERE, inside the transaction: the customer received ONE reply, and two
72
+ # concurrent mirror retries must not let the second rewrite the evidence.
73
+ # -> Pair
74
+ def record_incumbent(id:, channel:, event_id:, external_id:, reply:, at: nil)
75
+ upsert(id, at: at) do |record, created|
76
+ record["channel"] = channel.to_s
77
+ record["event_id"] = event_id.to_s
78
+ record["incumbent_reply"] = reply.to_s if record["incumbent_reply"].nil?
79
+ created
80
+ end
81
+ end
82
+
83
+ # -> Pair | nil
84
+ def find(id)
85
+ record = @store.get(SCOPE, key_for(id))
86
+ record && to_pair(record)
87
+ end
88
+
89
+ # Lazy scan over a SNAPSHOT of the keys (deleting under a live enumeration
90
+ # would skip records — the same rule OutboxStore applies).
91
+ def each(&block)
92
+ return enum_for(:each) unless block_given?
93
+
94
+ @store.list(SCOPE, KEY_PREFIX).each do |key|
95
+ record = @store.get(SCOPE, key)
96
+ yield to_pair(record) if record
97
+ end
98
+ end
99
+
100
+ # -> [Pair] created at or after `time`.
101
+ def since(time)
102
+ cutoff = time.utc.iso8601
103
+ each.select { |p| p.created_at.to_s >= cutoff }
104
+ end
105
+
106
+ # -> [Pair] status :complete, oldest first — the judging queue. `silent`
107
+ # pairs are NEVER here: finding that pairwise is
108
+ # systematically unfair to a tool that delivers out of band is not
109
+ # something to average away.
110
+ def unjudged(limit: nil, agent: nil)
111
+ pairs = each.select { |p| p.status == :complete }
112
+ .sort_by { |p| p.created_at.to_s }
113
+ pairs = pairs.select { |p| p.agent.to_s == agent.to_s } if agent
114
+ limit ? pairs.first(limit.to_i) : pairs
115
+ end
116
+
117
+ # -> { open:, complete:, silent:, judged:, incomplete: }
118
+ def counts(since: nil)
119
+ pairs = since ? self.since(since) : each.to_a
120
+ STATUSES.to_h { |s| [s, pairs.count { |p| p.status == s }] }
121
+ end
122
+
123
+ # The panel's Verdict as data. status -> :judged. -> Pair
124
+ def record_verdict(id, verdict:)
125
+ @store.transaction do
126
+ key = key_for(id)
127
+ record = @store.get(SCOPE, key)
128
+ raise Insika::NotFoundError, "shadow pair not found: #{id}" unless record
129
+
130
+ record["verdict"] = Coercion.deep_stringify(verdict)
131
+ record["status"] = "judged"
132
+ record["updated_at"] = timestamp
133
+ @store.set(SCOPE, key, record)
134
+ to_pair(record)
135
+ end
136
+ end
137
+
138
+ # An `open` pair older than the cutoff will never complete. -> count moved.
139
+ # `complete`/`silent`/`judged` are never touched. Update-style only: a pair
140
+ # deleted between the scan and the write (retention, LGPD purge) is left
141
+ # deleted — an upsert here would resurrect it as a ghost :incomplete record
142
+ # carrying none of its fields.
143
+ def expire(older_than:)
144
+ cutoff = older_than.utc.iso8601
145
+ moved = 0
146
+ each.select { |p| p.status == :open && p.created_at.to_s < cutoff }.each do |pair|
147
+ @store.transaction do
148
+ key = key_for(pair.id)
149
+ record = @store.get(SCOPE, key)
150
+ next unless record && record["status"] == "open"
151
+
152
+ record["status"] = "incomplete"
153
+ record["updated_at"] = timestamp
154
+ @store.set(SCOPE, key, record)
155
+ moved += 1
156
+ end
157
+ end
158
+ moved
159
+ end
160
+
161
+ # -> Integer. Keys only, no record materialization — a count of pairs must
162
+ # not pay for customer text (doctor's shadow-off check, the Studio).
163
+ def size = @store.list(SCOPE, KEY_PREFIX).length
164
+
165
+ # LGPD / retention (C9): drops every pair of these sessions, whatever its
166
+ # status — the pair holds the customer's own words. -> count removed.
167
+ def purge_sessions(session_ids)
168
+ wanted = Array(session_ids).map(&:to_s)
169
+ return 0 if wanted.empty?
170
+
171
+ doomed = each.select { |p| wanted.include?(p.session_id.to_s) }
172
+ doomed.each { |p| @store.delete(SCOPE, key_for(p.id)) }
173
+ doomed.size
174
+ end
175
+
176
+ # Retention: pairs created before the cutoff, TERMINAL statuses only
177
+ # (`judged`/`incomplete`) — an `open`/`complete` record older than the
178
+ # window is still someone's unjudged evidence. -> count removed.
179
+ def delete_older_than(time)
180
+ cutoff = time.utc.iso8601
181
+ doomed = each.select do |p|
182
+ %i[judged incomplete].include?(p.status) && p.created_at.to_s < cutoff
183
+ end
184
+ doomed.each { |p| @store.delete(SCOPE, key_for(p.id)) }
185
+ doomed.size
186
+ end
187
+
188
+ private
189
+
190
+ # The shared upsert: read -> merge only the fields THIS half owns (never
191
+ # overwrite the other's with nil) -> recompute status -> write. Runs inside
192
+ # Store#transaction so two halves landing in the same instant serialize on
193
+ # the backend's lock — the same claim mechanic OutboxStore#claim uses.
194
+ def upsert(id, at: nil, &fill)
195
+ @store.transaction do
196
+ key = key_for(id)
197
+ record = @store.get(SCOPE, key)
198
+ created = record.nil?
199
+ unless record
200
+ record = {
201
+ "id" => id.to_s, "channel" => nil, "agent" => nil, "session_id" => nil,
202
+ "task_id" => nil, "event_id" => nil, "inbound" => nil,
203
+ "incumbent_reply" => nil, "insika_reply" => nil, "status" => "open",
204
+ "verdict" => nil, "criterion_sha" => nil,
205
+ "created_at" => arrival_time(at), "updated_at" => timestamp
206
+ }
207
+ end
208
+ fill.call(record, created)
209
+ record["status"] = status_for(record)
210
+ record["updated_at"] = timestamp
211
+ @store.set(SCOPE, key, record)
212
+ to_pair(record)
213
+ end
214
+ end
215
+
216
+ # Status recomputation, in one place (never backwards — a judged pair stays
217
+ # judged, an expired one stays incomplete):
218
+ def status_for(record)
219
+ return record["status"] if %w[judged incomplete].include?(record["status"])
220
+ return "open" if record["insika_reply"].nil? || record["incumbent_reply"].nil?
221
+ return "silent" if record["insika_reply"].to_s.strip.empty?
222
+
223
+ "complete"
224
+ end
225
+
226
+ def key_for(id) = "#{KEY_PREFIX}#{id}"
227
+
228
+ # The mirror's reported time on first write; nil = now. A String rides
229
+ # through as-is (it is the wire format); a Time is normalized to ISO8601.
230
+ # A String is ALSO normalized to UTC ISO8601: the mirrors report local
231
+ # offsets (+09:00, -03:00) and every comparison against created_at
232
+ # (since/expire/retention/unjudged ordering) is lexicographic — two offsets
233
+ # would make those comparisons lie. Unparseable input keeps the old
234
+ # ride-through behaviour rather than refusing the pair.
235
+ def arrival_time(at)
236
+ return timestamp if at.nil?
237
+ return at.utc.iso8601 unless at.is_a?(String)
238
+
239
+ Time.iso8601(at).utc.iso8601
240
+ rescue ArgumentError
241
+ at
242
+ end
243
+
244
+ def to_pair(record)
245
+ Pair.new(
246
+ id: record["id"], channel: record["channel"], agent: record["agent"],
247
+ session_id: record["session_id"], task_id: record["task_id"],
248
+ event_id: record["event_id"], inbound: record["inbound"],
249
+ incumbent_reply: record["incumbent_reply"], insika_reply: record["insika_reply"],
250
+ status: record["status"].to_sym, verdict: record["verdict"],
251
+ criterion_sha: record["criterion_sha"],
252
+ created_at: record["created_at"], updated_at: record["updated_at"]
253
+ )
254
+ end
255
+
256
+ def timestamp = Time.now.utc.iso8601
257
+ end
258
+ end
@@ -1,15 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Insika
4
- # RFC-0016 A3 — shutdown is a drain, not a kill (docs/DEPLOY.md, process model
5
- # item 4). The serving arms install this around the Executor. On the first
4
+ # shutdown is a drain, not a kill (docs/DEPLOY.md, process model
5
+ # The serving arms install this around the Executor. On the first
6
6
  # SIGTERM/SIGINT the process stops accepting new turns (`Executor#begin_drain!`
7
7
  # — a turn arriving mid-drain is left `:queued` for the next boot's recovery)
8
8
  # and waits up to `timeout` seconds for the in-flight ones; only then does the
9
9
  # ordinary stop proceed. A second signal skips the wait — the operator insisting
10
10
  # means now. Whatever the deadline abandons dies `:running` with the process and
11
11
  # the next boot generation's task sweep replays it from its checkpoint
12
- # (side-effect skip on resume is what makes that replay safe, RFC-0006).
12
+ # (side-effect skip on resume is what makes that replay safe).
13
13
  #
14
14
  # Mechanics, because trap context is narrow: the handler writes ONE byte into a
15
15
  # self-pipe and returns. A plain watcher THREAD — not a fiber: at install time
@@ -27,7 +27,7 @@ module Insika
27
27
  # traps, parks the watcher. The CALLING thread is captured as the stop target
28
28
  # — install from the thread that runs the server.
29
29
  #
30
- # `executors:` (RFC-0017 A4) drains N graphs on one signal. Signals are a
30
+ # `executors:` drains N graphs on one signal. Signals are a
31
31
  # PROCESS concern, and `Signal.trap` keeps only the last handler — so a second
32
32
  # `install` per graph would silently leave the earlier graphs dying mid-turn.
33
33
  # The host installs ONCE, naming every graph it embedded. `executor:` is the
@@ -11,7 +11,16 @@ module Insika
11
11
  # Consumed by the Executor (skill_catalog:) and by stage 3
12
12
  # (effective/format_for_prompt).
13
13
  class SkillCatalog
14
- Skill = Data.define(:name, :description, :path, :body)
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)
15
24
 
16
25
  # roots ordered by PRECEDENCE (highest first): workspace, managed,
17
26
  # bundled. Same name in more than one root: the first wins.
@@ -22,37 +31,97 @@ module Insika
22
31
  def initialize(roots, store: nil)
23
32
  @roots = Array(roots)
24
33
  @store = store
25
- @skills = load_all
34
+ @skills, @agent_skills = load_all
26
35
  end
27
36
 
28
- def all
29
- @skills.values
37
+ # The SkillStore the catalog overlays — the composition root hands it to
38
+ # the harvest (the dedup reads the AUTHORED skills the catalog serves).
39
+ attr_reader :store
40
+
41
+ # `agent` (an agent id) resolves the AGENT SCOPE first, then the shared one — the
42
+ # same precedence chain the catalog already runs for store-over-disk and
43
+ # workspace-over-managed-over-bundled, with one more dimension.
44
+ #
45
+ # Three cases fall out of that one rule: SHARED (only the shared record exists),
46
+ # OVERRIDE (both exist, the agent's wins) and AGENT-PRIVATE (only the agent record
47
+ # exists — invisible elsewhere, and its name may collide freely).
48
+ #
49
+ # Without `agent` the shared scope is all there is, which is what every caller
50
+ # that has no agent in hand (the Studio's shared editor, a bare catalog) means.
51
+ def all(agent: nil)
52
+ shared = @skills
53
+ overrides = agent_scope(agent)
54
+ return shared.values if overrides.empty?
55
+
56
+ shared.merge(overrides).values
30
57
  end
31
58
 
32
- def find(name)
33
- @skills[name.to_s]
59
+ def find(name, agent: nil)
60
+ agent_scope(agent)[name.to_s] || @skills[name.to_s]
34
61
  end
35
62
 
36
63
  # Reloads from disk + Store and SWAPS the index atomically: an
37
64
  # authored/edited skill takes effect without a restart. A turn in progress
38
65
  # captured @skills at dispatch, so it does not see the swap mid-flight.
39
66
  def reload
40
- @skills = load_all
67
+ @skills, @agent_skills = load_all
41
68
  self
42
69
  end
43
70
 
44
- # Per-agent allowlist: nil -> all | [] -> none | [names] -> subset.
45
- def effective(skills_policy)
46
- Allowlist.filter(all, skills_policy) { |s| s.name }
71
+ # Per-agent allowlist: nil -> all | [] -> none | [names] -> subset. `agent`
72
+ # selects WHICH body each allowed name resolves to (see #find); the allowlist is
73
+ # by NAME either way, so specializing a skill never touches the allowlist.
74
+ def effective(skills_policy, agent: nil)
75
+ Allowlist.filter(all(agent: agent), skills_policy) { |s| s.name }
47
76
  end
48
77
 
78
+ # THE single definition of "always in the prompt", consulted by all three
79
+ # surfaces that must agree: the body provider (injects these), the level-1
80
+ # catalog (hides them) and load_skill (refuses them). Split the rule across three
81
+ # files and they drift — which is the failure this whole feature came from.
82
+ #
83
+ # `profile.skills_eager` — a PER-AGENT decision, so a shared skill stays shared:
84
+ # nil | false -> none (progressive disclosure; the default)
85
+ # true -> every allowed skill (blanket; only for a corpus that fits the budget)
86
+ # [names] -> exactly these
87
+ #
88
+ # Deliberately NOT `Allowlist.filter`: there nil means ALL, which is the safe
89
+ # default for `skills`/`tools_allow` where nil is "no policy". Here nil must mean
90
+ # NONE — an unconfigured agent waking up with every skill body on every turn is
91
+ # the opposite of a safe default. A name that is not in the agent's `skills`
92
+ # allowlist is a silent no-op here (the intersection with `effective`); `doctor`
93
+ # flags it, because the operator who wrote the name meant it.
94
+ def eager_for(profile)
95
+ allowed = effective(profile.skills, agent: profile.id)
96
+ spec = profile.skills_eager
97
+ return allowed if blanket?(spec)
98
+ return [] if spec.nil? || spec == false
99
+
100
+ names = Array(spec).map { |n| n.to_s.strip }
101
+ allowed.select { |s| names.include?(s.name) }
102
+ end
103
+
104
+ # The complement: what the model still has to ASK for — and therefore what the
105
+ # level-1 list advertises and load_skill will serve.
106
+ def lazy_for(profile) = effective(profile.skills, agent: profile.id) - eager_for(profile)
107
+
49
108
  # Level 1: compact list injected into the system prompt. Metadata only.
50
109
  # Receives the set already filtered by the agent.
110
+ #
111
+ # `when=` carries the skill's `triggers:` — THE ROUTING TABLE, GENERATED. What
112
+ # actually made activation reliable on the pilot was a hand-written companion file
113
+ # listing each skill with its trigger phrases, and nothing checked it against the
114
+ # catalog: a skill created at 11:28 was invisible to a table written the day
115
+ # before, and the model obeyed the table. Rendering the same information from the
116
+ # catalog means it cannot disagree with the allowlist — a newly allowed skill
117
+ # appears the moment it is allowed. Detecting that drift would have been strictly
118
+ # worse than removing its source.
51
119
  def format_for_prompt(skills = all)
52
120
  return "" if skills.empty?
53
121
 
54
122
  entries = skills.map do |s|
55
- %( <skill name="#{s.name}">#{s.description}</skill>)
123
+ when_attr = Array(s.triggers).empty? ? "" : %( when="#{Array(s.triggers).join('; ')}")
124
+ %( <skill name="#{s.name}"#{when_attr}>#{s.description}</skill>)
56
125
  end.join("\n")
57
126
 
58
127
  <<~PROMPT.strip
@@ -60,13 +129,24 @@ module Insika
60
129
  #{entries}
61
130
  </available_skills>
62
131
 
63
- Before acting on a task that matches a skill above, call the
64
- `load_skill` tool with its name to load the complete instructions.
132
+ Before ANY reply or tool call: scan the skills above. If one matches
133
+ or is even partially relevant to the task, you MUST call
134
+ `load_skill("name")` FIRST and follow what it returns. Err on the
135
+ side of loading. Only skip when genuinely none apply.
65
136
  PROMPT
66
137
  end
67
138
 
68
139
  private
69
140
 
141
+ # The blanket switch, tolerant of the strings a form / JSON round-trip produces
142
+ # ("1" from a checkbox, "true" from a pack) — same reading as
143
+ # AgentProfile#stream_public?. Anything else (a list, nil, false) is not blanket.
144
+ def blanket?(spec) = Coercion.truthy?(spec)
145
+
146
+ # An agent's override index; {} for a nil agent or one that specialized nothing.
147
+ def agent_scope(agent) = agent.nil? ? {} : (@agent_skills[agent.to_s] || {})
148
+
149
+ # -> [shared index, { agent_id => index }].
70
150
  def load_all
71
151
  found = {}
72
152
  @roots.each do |root|
@@ -78,7 +158,7 @@ module Insika
78
158
  end
79
159
  end
80
160
  overlay_store(found)
81
- found
161
+ [found, load_agent_scopes]
82
162
  end
83
163
 
84
164
  # Store skills overlay the on-disk ones (authored > seed). Sentinel path
@@ -87,27 +167,58 @@ module Insika
87
167
  return unless @store
88
168
 
89
169
  @store.all.each do |name, content|
90
- skill = parse_content(content.to_s, path: "store:#{name}")
91
- found[skill.name] = skill if skill # Store wins
170
+ skill = parse_content(content.to_s, path: "store:#{name}", key: name)
171
+ found[name.to_s] = skill if skill # Store wins
92
172
  end
93
173
  end
94
174
 
95
- def parse_content(raw, path:)
175
+ # Per-agent overrides / private skills, one index per agent. A store that predates
176
+ # the agent dimension answers nothing here, so this is {} and every lookup falls
177
+ # straight through to the shared scope.
178
+ def load_agent_scopes
179
+ return {} unless @store.respond_to?(:agents)
180
+
181
+ @store.agents.each_with_object({}) do |agent, acc|
182
+ index = {}
183
+ @store.all(agent: agent).each do |name, content|
184
+ skill = parse_content(content.to_s, path: "store:#{agent}/#{name}", key: name)
185
+ index[name.to_s] = skill if skill
186
+ end
187
+ acc[agent.to_s] = index unless index.empty?
188
+ end
189
+ end
190
+
191
+ # `key` = THE STORE POSITION, and it wins over the frontmatter `name:`. An override
192
+ # authored for one agent still says `name: escalation-to-human` inside — that is
193
+ # deliberate, it is the same skill specialized — and indexing by the parsed name
194
+ # would clobber the shared record globally, which is the exact bug the agent scope
195
+ # exists to fix. It also makes a pack whose directory name and frontmatter name
196
+ # disagree resolvable: the allowlist is written from the directory.
197
+ def parse_content(raw, path:, key: nil)
96
198
  match = raw.match(/\A---\s*\n(.*?)\n---\s*\n(.*)\z/m)
97
199
  return nil unless match
98
200
 
99
201
  # Tolerant frontmatter: real packs have `: ` in the description prose, which
100
202
  # strict YAML rejected (the pack would not load).
101
203
  meta = Insika::Frontmatter.parse(match[1])
102
- name = meta["name"]
103
- return nil unless name
204
+ name = key || meta["name"]
205
+ return nil unless name && Coercion.present?(meta["name"])
104
206
 
105
207
  Skill.new(
106
208
  name: name.to_s,
107
209
  description: meta["description"].to_s,
108
210
  path: path,
109
- body: match[2].strip
211
+ body: match[2].strip,
212
+ triggers: parse_list(meta["triggers"]),
213
+ companions: parse_list(meta["companions"])
110
214
  )
111
215
  end
216
+
217
+ # `triggers:` / `companions:` frontmatter. YAML list, or comma-separated string
218
+ # under the lenient parse (which yields the whole value as one String).
219
+ def parse_list(raw)
220
+ list = raw.is_a?(String) ? raw.split(",") : Array(raw)
221
+ list.map { |t| t.to_s.strip }.reject(&:empty?)
222
+ end
112
223
  end
113
224
  end
@@ -3,20 +3,37 @@
3
3
  require "time"
4
4
 
5
5
  module Insika
6
- # AUTHORED shared skills.
6
+ # AUTHORED skills, in two scopes.
7
7
  # Holds the complete SKILL.md (frontmatter + body) in the durable Store. The
8
8
  # SkillCatalog overlays these skills on top of the on-disk ones (seed), with the Store
9
9
  # winning — so editing/creating a skill in the Studio takes effect without a restart (via reload).
10
10
  #
11
- # One record per skill in the ConfigStore (scope "skills"):
11
+ # SHARED scope (`agent:` omitted) — one record per skill in the ConfigStore
12
+ # (scope "skills"), keyed by the skill name:
12
13
  # { "content" => "<entire SKILL.md>",
13
14
  # "updated_at" => iso8601,
14
15
  # "history" => [ { "content" =>, "at" => }, ... ] }
15
16
  #
16
- # The key is the skill's canonical name (the same as in the frontmatter). Versions like
17
- # the AgentFileStore.
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.
18
34
  class SkillStore
19
35
  SCOPE = "skills"
36
+ AGENT_SCOPE = "agent_skills"
20
37
  HISTORY_MAX = 20
21
38
 
22
39
  def initialize(config_store:)
@@ -24,48 +41,79 @@ module Insika
24
41
  end
25
42
 
26
43
  # -> String | nil (complete SKILL.md).
27
- def get(name)
28
- record(name)&.fetch("content", nil)
44
+ def get(name, agent: nil)
45
+ record(name, agent)&.fetch("content", nil)
29
46
  end
30
47
 
31
- # -> [String] names, lexicographic order.
32
- def names = @cs.keys(SCOPE)
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
33
52
 
34
- # -> { name => content } of all authored skills.
35
- def all
36
- @cs.keys(SCOPE).each_with_object({}) { |n, acc| acc[n] = get(n) }
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) }
37
56
  end
38
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
+
39
62
  # Writes (upsert). create_only refuses to overwrite. -> Hash (the stored record).
40
- def write(name, content, create_only: false)
63
+ def write(name, content, agent: nil, create_only: false)
41
64
  key = name.to_s
42
- current = @cs.get(SCOPE, key)
43
- raise Insika::ValidationError, "skill '#{key}' already exists" if create_only && current
65
+ current = record(key, agent)
66
+ raise Insika::ValidationError, "skill '#{key}' already exists#{" for agent '#{agent}'" if agent}" if create_only && current
44
67
 
45
68
  rec = build_record(content.to_s, current)
46
- @cs.put(SCOPE, key, rec)
69
+ put(key, rec, agent)
47
70
  rec
48
71
  end
49
72
 
50
73
  # -> bool (did it exist?).
51
- def delete(name) = @cs.delete(SCOPE, name.to_s)
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
52
85
 
53
86
  # -> [ { "content" =>, "at" => } ] most recent first.
54
- def versions(name) = record(name)&.fetch("history", []) || []
87
+ def versions(name, agent: nil) = record(name, agent)&.fetch("history", []) || []
55
88
 
56
89
  # Restores version `index` as the current content (a new write). -> Hash.
57
- def restore(name, index)
58
- hist = versions(name)
90
+ def restore(name, index, agent: nil)
91
+ hist = versions(name, agent: agent)
59
92
  i = Integer(index)
60
- raise Insika::NotFoundError, "skill '#{name}' not found" unless record(name)
93
+ raise Insika::NotFoundError, "skill '#{name}' not found" unless record(name.to_s, agent)
61
94
  raise Insika::ValidationError, "version #{index} does not exist" if i.negative? || i >= hist.length
62
95
 
63
- write(name, hist[i]["content"])
96
+ write(name, hist[i]["content"], agent: agent)
64
97
  end
65
98
 
66
99
  private
67
100
 
68
- def record(name) = @cs.get(SCOPE, name.to_s)
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"] || {}
69
117
 
70
118
  def build_record(content, current)
71
119
  history = current ? current.fetch("history", []) : []