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,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ module Providers
6
+ # Read path for the session briefing: the per-session
7
+ # working-state the agent keeps and asks for. Thin adapter over the
8
+ # SessionStore, same pattern as Memory: one `:system` fragment,
9
+ # deterministic. The MISSING list is rendered, never implied — that list
10
+ # is what stops the model re-asking for a field already given.
11
+ class Briefing < ContextProvider
12
+ def initialize(session_store:)
13
+ @session_store = session_store
14
+ end
15
+
16
+ # Stable id -> the context-trace category "briefing".
17
+ def id = "briefing"
18
+
19
+ # Pack-declared: no briefing_fields -> no provider (the Builder still
20
+ # applies the `context_providers` allowlist on top — two gates, like Memory).
21
+ def enabled_for?(profile)
22
+ fields = profile.respond_to?(:briefing_fields) ? profile.briefing_fields : nil
23
+ !Array(fields).empty?
24
+ end
25
+
26
+ # required? == false (default): a store failure degrades via the Builder's
27
+ # warning path, never aborts the turn.
28
+ def call(request)
29
+ session = request.respond_to?(:session) ? request.session : nil
30
+ return [] if session.nil? # one-shot turns have no briefing
31
+
32
+ briefing = briefing_for(session)
33
+ fields = briefing["fields"] || {}
34
+ declared = Array(request.profile.briefing_fields)
35
+ return [] if declared.empty? # defensive; enabled_for? already gates
36
+
37
+ block = format_block(declared, fields, briefing["next_step"])
38
+ return [] if block.nil?
39
+
40
+ [ContextFragment.build(content: block, placement: :system,
41
+ priority: Context::Priority::BRIEFING,
42
+ source: id)]
43
+ end
44
+
45
+ private
46
+
47
+ # Re-reads the briefing from the store, like the Session provider: the
48
+ # persisted record is the source of truth, not the request's turn-start
49
+ # snapshot. A read failure propagates to the Builder, which degrades it
50
+ # to a :provider_warning (required? == false).
51
+ def briefing_for(session)
52
+ @session_store.find(session.id)&.briefing || {}
53
+ end
54
+
55
+ # Byte contract (the specs assert this shape):
56
+ # <briefing>
57
+ # known:
58
+ # size: M
59
+ # still missing: delivery_day
60
+ # next step: send the payment link tomorrow at 10
61
+ # </briefing>
62
+ # Rules: `known` renders only when at least one declared field has a
63
+ # stored value; `still missing` renders every declared field with no
64
+ # stored value (including the all-missing case — that is the block's
65
+ # job); `next step` renders only when non-nil; stored keys NOT in the
66
+ # declaration are never rendered (they stay in the store and reappear if
67
+ # the pack re-declares them).
68
+ def format_block(declared, fields, next_step)
69
+ known = declared.filter_map do |name|
70
+ " #{name}: #{flatten(fields[name])}" if Coercion.present?(fields[name])
71
+ end
72
+ missing = declared.reject { |name| Coercion.present?(fields[name]) }
73
+
74
+ lines = []
75
+ lines << "known:" unless known.empty?
76
+ lines.concat(known)
77
+ lines << "still missing: #{missing.join(', ')}" unless missing.empty?
78
+ lines << "next step: #{flatten(next_step)}" if Coercion.present?(next_step)
79
+ return nil if lines.empty?
80
+
81
+ <<~BLOCK.strip
82
+ <briefing>
83
+ #{lines.join("\n")}
84
+ </briefing>
85
+ BLOCK
86
+ end
87
+
88
+ # utf8 the value and flatten newlines/whitespace so a value can never
89
+ # break the block's line structure.
90
+ def flatten(value)
91
+ Coercion.utf8(value.to_s).gsub(/\s+/, " ").strip
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -20,7 +20,7 @@ module Insika
20
20
  # required? == false (default): a failure (store unavailable) becomes a
21
21
  # :provider_warning + graceful degradation — never aborts the turn.
22
22
  def call(request)
23
- tenant = memory_tenant(request)
23
+ tenant = memory_scope(request)
24
24
  facts = @store.facts(tenant: tenant)
25
25
  notes = @store.notes(tenant: tenant, limit: @notes_limit)
26
26
  return [] if facts.empty? && notes.empty?
@@ -33,15 +33,24 @@ module Insika
33
33
 
34
34
  private
35
35
 
36
- # Engine memory scope (D3): an EXPLICIT tenant from the Command wins
37
- # (multi-merchant override); otherwise the SESSION (=chat) — engine-owner
38
- # memory is per-chat. No session (one-shot) and no tenant -> nil (MemoryStore
39
- # applies _default). Symmetric to the write path (`state.tenant` in the Executor).
40
- def memory_tenant(request)
36
+ # Engine memory scope (WS8): the request's CUSTOMER-scoped cell
37
+ # ("[tenant:]customer" — engine-owner memory is per customer, never per
38
+ # tenant) wins; otherwise an EXPLICIT tenant from the Command (the
39
+ # multi-merchant override); otherwise the SESSION (=chat), MARKED like
40
+ # the write path ("chat:<session id>" — : a session cell is
41
+ # never a bare cell, so the drill cannot read a conversation as a
42
+ # customer). No session (one-shot) and no tenant -> nil (MemoryStore
43
+ # applies _default). Symmetric to the write path (`state.tenant` in the
44
+ # Executor).
45
+ def memory_scope(request)
46
+ scoped = request.respond_to?(:memory_scope) ? request.memory_scope : nil
47
+ return scoped if scoped
48
+
41
49
  explicit = request.respond_to?(:tenant) ? request.tenant : nil
42
50
  return explicit if explicit
43
51
 
44
- request.respond_to?(:session) ? request.session&.id : nil
52
+ session = request.respond_to?(:session) ? request.session : nil
53
+ session && session.id ? "#{Insika::MemoryStore::SESSION_TAG}:#{session.id}" : nil
45
54
  end
46
55
 
47
56
  # Passive <memory> (no instruction — the HOW of writing lives in the `remember` tool).
@@ -10,11 +10,26 @@ module Insika
10
10
  #
11
11
  # PER-AGENT identity. `profile.prompt_files` (file names)
12
12
  # wins over the wiring's `files:` — this fixes the limitation of a new
13
- # agent inheriting Bia's prompt. The content comes from `agent_files`
13
+ # agent inheriting the previous persona's prompt. The content comes from `agent_files`
14
14
  # (AgentFileStore, lives in the Store), with a File.read fallback
15
15
  # for on-disk paths (compat/seed). Without prompt_files -> uses the wiring's
16
16
  # `files:` (deployment default; byte-for-byte parity).
17
17
  class Prompt < ContextProvider
18
+ # Engine-owned execution discipline, appended AFTER the agent's identity.
19
+ # The one behavior every reference harness bakes into its base prompt
20
+ # (OpenClaw's "Execution Bias") and this engine was missing: a weak tool
21
+ # result read as final. A constant — byte-identical every turn, so
22
+ # prompt_caching pays ONE write on the deploy that introduces it, never
23
+ # per turn. Opt-out per profile (`tool_persistence false`), the single
24
+ # default-ON profile flag: the proven-good behavior is the default, the
25
+ # exception is the thing an operator declares.
26
+ TOOL_PERSISTENCE = "## Tool discipline\n" \
27
+ "- Weak or empty tool result: try again with a different approach — rephrase the " \
28
+ "query, use a synonym or broader term, drop a secondary filter — before telling " \
29
+ "the user you found nothing. Do not narrate the retries. Then conclude.\n" \
30
+ "- Tool error: read the error, fix the arguments or try another path; never " \
31
+ "repeat the exact same call."
32
+
18
33
  def initialize(base: "", files: [], catalog: nil, agent_files: nil, system_files: nil)
19
34
  @base = base
20
35
  @files = Array(files)
@@ -24,6 +39,8 @@ module Insika
24
39
  end
25
40
 
26
41
  def required? = true
42
+ # identity (config/agent-file derived — already pinned).
43
+ def layer = :identity
27
44
 
28
45
  def call(request)
29
46
  fragments = []
@@ -62,7 +79,18 @@ module Insika
62
79
  else
63
80
  sources.each { |src| parts << read_source(profile&.id, src.to_s) }
64
81
  end
65
- parts.reject { |p| p.nil? || p.strip.empty? }.join("\n\n")
82
+ identity = parts.reject { |p| p.nil? || p.strip.empty? }.join("\n\n")
83
+ # Discipline rides an EXISTING identity, never substitutes one: an
84
+ # agent with no identity at all must stay detectably empty.
85
+ return identity if identity.empty? || !tool_persistence?(profile)
86
+
87
+ "#{identity}\n\n#{TOOL_PERSISTENCE}"
88
+ end
89
+
90
+ # nil/absent/true = ON (the engine default); only an explicit `false`
91
+ # turns it off. Defensive respond_to?: a minimal profile stub reads ON.
92
+ def tool_persistence?(profile)
93
+ !(profile.respond_to?(:tool_persistence) && profile.tool_persistence == false)
66
94
  end
67
95
 
68
96
  # GLOBAL system files: apply to every agent,
@@ -13,7 +13,7 @@ module Insika
13
13
  # "history" is transcript (consumed by the Session provider), not turn
14
14
  # metadata — it does not leak into the system's request_context. Keys
15
15
  # prefixed with "__" are INTERNAL slots (e.g. the per-chat model pin
16
- # "__llm__", §10) — reserved, never rendered to the model.
16
+ # "__llm__") — reserved, never rendered to the model.
17
17
  request.vars.to_h.each do |k, v|
18
18
  next if k.to_s == "history" || k.to_s.start_with?("__")
19
19
 
@@ -17,7 +17,13 @@ module Insika
17
17
  messages = transcript_for(request)
18
18
  return [] if messages.nil? || messages.empty?
19
19
 
20
- # 1 fragment per EVICTION UNIT (§11 R1): a plain message, OR an
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
21
27
  # assistant-with-tool_calls together with its tool results. Grouping at
22
28
  # the fragment level means the budget cut (apply_budget) drops a whole
23
29
  # tool cycle atomically — a tool_use is NEVER seeded without its result
@@ -72,7 +78,16 @@ module Insika
72
78
  h
73
79
  end
74
80
 
75
- def tool_calls?(msg) = msg[:tool_calls] && !Array(msg[:tool_calls]).empty?
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
76
91
 
77
92
  # Precedence: checkpoint -> explicit history -> store.
78
93
  # The first present source wins; no merge.
@@ -10,10 +10,16 @@ module Insika
10
10
  class Skill < CatalogProvider
11
11
  # priority 80: above deferred tools (70), below pinned identity.
12
12
  def priority = Context::Priority::SKILL
13
+ # catalog + allowlist — config only.
14
+ def layer = :identity
13
15
 
14
16
  private
15
17
 
16
- def entries(request) = @catalog.effective(request.profile.skills)
18
+ # Only the skills the model still has to ASK for. An eager skill is already in
19
+ # the prompt in full, so advertising it here would invite a `load_skill` call
20
+ # that buys a duplicate — and the catalog's whole job is to describe what is
21
+ # NOT yet loaded. Nothing lazy left -> CatalogProvider emits no fragment.
22
+ def entries(request) = @catalog.lazy_for(request.profile)
17
23
  end
18
24
  end
19
25
  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
@@ -10,6 +10,8 @@ module Insika
10
10
  class ToolSearch < CatalogProvider
11
11
  # priority 70: below skills (80) in the sacrifice order.
12
12
  def priority = Context::Priority::TOOL_SEARCH
13
+ # tool registry + tools_deferred — config only.
14
+ def layer = :identity
13
15
 
14
16
  private
15
17
 
@@ -0,0 +1,128 @@
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. -> the sanitized entry (parks it on TurnState for the
28
+ # stage-8 cache merge).
29
+ def record(session_id:, entry:)
30
+ sid = session_id.to_s
31
+ return if sid.empty?
32
+
33
+ e = sanitize(entry)
34
+ key = [e["task_id"], e["turn"]]
35
+ list = (@store.get(SCOPE, sid) || []).reject { |x| [x["task_id"], x["turn"]] == key } + [e]
36
+ @store.set(SCOPE, sid, list.last(MAX_PER_SESSION))
37
+ e
38
+ rescue StandardError
39
+ nil
40
+ end
41
+
42
+ # -> [Hash] session entries in chronological order. [] if none.
43
+ def for_session(session_id) = @store.get(SCOPE, session_id.to_s) || []
44
+
45
+ # Discards a session's trace (cleanup). -> bool (did it exist?).
46
+ def clear(session_id) = @store.delete(SCOPE, session_id.to_s)
47
+
48
+ private
49
+
50
+ # Keeps only the known shape; coerces numbers and strings so a caller bug
51
+ # degrades the card instead of poisoning the record.
52
+ def sanitize(entry)
53
+ e = entry.is_a?(Hash) ? entry : {}
54
+ categories = e[:categories] || e["categories"] || {}
55
+ tools = e[:tools] || e["tools"] || {}
56
+ {
57
+ "task_id" => (e[:task_id] || e["task_id"]).to_s,
58
+ "turn" => int(e[:turn] || e["turn"]),
59
+ "at" => (e[:at] || e["at"]).to_s,
60
+ "cap" => int(e[:cap] || e["cap"]),
61
+ "used" => int(e[:used] || e["used"]),
62
+ "evicted" => Array(e[:evicted] || e["evicted"]).map(&:to_s),
63
+ "categories" => categories.each_with_object({}) do |(name, c), acc|
64
+ c = {} unless c.is_a?(Hash)
65
+ cat = { "tokens" => int(c[:tokens] || c["tokens"]),
66
+ "fragments" => int(c[:fragments] || c["fragments"]),
67
+ "pinned" => int(c[:pinned] || c["pinned"]) }
68
+ # which cache layer the category belongs to ("identity" |
69
+ # "volatile"). Absent for a category recorded before the contract (or
70
+ # one that never learned it) — the view guards on nil.
71
+ layer = c[:layer] || c["layer"]
72
+ cat["layer"] = layer.to_s if layer
73
+ # WHAT the category carried and WHY ({name, reason}) — still ids only, so
74
+ # the no-masking-needed contract above holds. Omitted when empty: most
75
+ # categories have nothing to name and an empty key is just noise.
76
+ labels = normalize_labels(c[:labels] || c["labels"])
77
+ cat["labels"] = labels unless labels.empty?
78
+ acc[name.to_s] = cat
79
+ end,
80
+ "tools" => { "count" => int(tools[:count] || tools["count"]),
81
+ "tokens" => int(tools[:tokens] || tools["tokens"]) },
82
+ "fingerprints" => fingerprints_of(e[:fingerprints] || e["fingerprints"]),
83
+ "cache" => cache_of(e[:cache] || e["cache"])
84
+ }.compact
85
+ end
86
+
87
+ # { name => sha256-hex }; names stringified, non-strings
88
+ # dropped. Absent when the caller passed nothing (a trace recorded before
89
+ # this feature has no key and the view guards on nil).
90
+ def fingerprints_of(raw)
91
+ return nil unless raw.is_a?(Hash) && !raw.empty?
92
+
93
+ raw.each_with_object({}) do |(name, hex), acc|
94
+ acc[name.to_s] = hex.to_s if hex.is_a?(String)
95
+ end.then { |h| h.empty? ? nil : h }
96
+ end
97
+
98
+ # { hit_pct, cached_tokens, prompt_tokens, invalidation_reason }.
99
+ # Unknown keys dropped. Present only when the caller passed it.
100
+ def cache_of(raw)
101
+ return nil unless raw.is_a?(Hash)
102
+
103
+ c = {
104
+ "hit_pct" => int_or_nil(raw[:hit_pct] || raw["hit_pct"]),
105
+ "cached_tokens" => int(raw[:cached_tokens] || raw["cached_tokens"]),
106
+ "prompt_tokens" => int(raw[:prompt_tokens] || raw["prompt_tokens"]),
107
+ "invalidation_reason" => raw[:invalidation_reason] || raw["invalidation_reason"]
108
+ }
109
+ c["invalidation_reason"] = c["invalidation_reason"].to_s unless c["invalidation_reason"].nil?
110
+ c
111
+ end
112
+
113
+ # Labels are {name, reason} in string keys (ContextFragment.label). A bare string
114
+ # still reads as a nameless-reason label: an entry recorded before reasons existed,
115
+ # or a caller that only has the id, degrades the card instead of poisoning it.
116
+ def normalize_labels(raw)
117
+ Array(raw).filter_map do |label|
118
+ name, reason = label.is_a?(Hash) ? [label[:name] || label["name"], label[:reason] || label["reason"]] : [label, nil]
119
+ next if name.to_s.empty?
120
+
121
+ { "name" => name.to_s, "reason" => reason&.to_s }.compact
122
+ end.uniq
123
+ end
124
+
125
+ def int(value) = Integer(value || 0)
126
+ def int_or_nil(value) = value.nil? ? nil : Integer(value)
127
+ end
128
+ end
@@ -4,8 +4,8 @@ require "securerandom"
4
4
  require "time"
5
5
 
6
6
  module Insika
7
- # Durable record of an ASYNC delegation (RFC-0010 §5, item 21 Phase 2, hermes
8
- # "delegation durability"). The synchronous subagent (Phase 1) needs no
7
+ # Durable record of an ASYNC delegation (hermes
8
+ # "delegation durability"). The synchronous subagent needs no
9
9
  # record — it lives and dies inside the parent's turn. The ASYNC subagent does:
10
10
  # the parent DISPATCHES and its turn ends; the child runs independently; when the
11
11
  # child finishes, its result is delivered to the parent as a NEW turn (never