insika 0.1.0 → 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 (182) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -3
  3. data/README.md +1 -1
  4. data/bin/insika +22 -7
  5. data/docs/AGENTS.md +129 -5
  6. data/docs/CHANNELS.md +1 -1
  7. data/docs/CONTEXT.md +22 -5
  8. data/docs/DEPLOY.md +30 -10
  9. data/docs/EMBEDDING.md +11 -7
  10. data/docs/EVALS.md +1 -1
  11. data/docs/LOADTEST.md +3 -2
  12. data/docs/OBSERVABILITY.md +11 -2
  13. data/docs/REFINEMENT.md +6 -6
  14. data/docs/RELEASING.md +7 -7
  15. data/docs/RUNNING-LOCAL.md +1 -1
  16. data/docs/SECURITY.md +24 -11
  17. data/docs/SKILLS.md +189 -3
  18. data/docs/WHY.md +1 -1
  19. data/docs/WORKFLOWS.md +2 -2
  20. data/docs/index.md +1 -1
  21. data/docs/onboarding/start.md +1 -1
  22. data/lib/insika/agent_profile.rb +89 -22
  23. data/lib/insika/alert_dispatcher.rb +139 -0
  24. data/lib/insika/baseline_store.rb +2 -2
  25. data/lib/insika/budget_ledger.rb +135 -0
  26. data/lib/insika/channel_delivery.rb +14 -11
  27. data/lib/insika/channel_registry.rb +1 -1
  28. data/lib/insika/channels/relay.rb +3 -3
  29. data/lib/insika/channels/web/widget.js +2 -2
  30. data/lib/insika/channels/web.rb +7 -7
  31. data/lib/insika/channels/webhook.rb +58 -0
  32. data/lib/insika/chat_builder.rb +62 -13
  33. data/lib/insika/circuit_state.rb +114 -0
  34. data/lib/insika/coercion.rb +8 -0
  35. data/lib/insika/commands/agent_payload.rb +5 -3
  36. data/lib/insika/commands/create_agent.rb +2 -2
  37. data/lib/insika/commands/create_session.rb +1 -1
  38. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  39. data/lib/insika/commands/delete_skill.rb +43 -0
  40. data/lib/insika/commands/gate_refinement.rb +12 -12
  41. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  42. data/lib/insika/commands/import_tools.rb +4 -4
  43. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  44. data/lib/insika/commands/resolve_refinement.rb +1 -1
  45. data/lib/insika/commands/revoke_token.rb +39 -0
  46. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  47. data/lib/insika/commands/run_refinement.rb +5 -5
  48. data/lib/insika/commands/send_message.rb +9 -9
  49. data/lib/insika/commands/set_agent_tools.rb +1 -1
  50. data/lib/insika/commands/set_skill_agents.rb +60 -19
  51. data/lib/insika/commands/trigger_workflow.rb +1 -1
  52. data/lib/insika/commands/update_agent.rb +1 -1
  53. data/lib/insika/commands/write_data_tool.rb +1 -1
  54. data/lib/insika/commands/write_golden.rb +1 -1
  55. data/lib/insika/commands/write_skill.rb +19 -9
  56. data/lib/insika/config_store.rb +8 -4
  57. data/lib/insika/context/builder.rb +2 -2
  58. data/lib/insika/context/fragment.rb +27 -3
  59. data/lib/insika/context/priority.rb +3 -2
  60. data/lib/insika/context/providers/memory.rb +1 -1
  61. data/lib/insika/context/providers/request.rb +1 -1
  62. data/lib/insika/context/providers/session.rb +17 -2
  63. data/lib/insika/context/providers/skill.rb +5 -1
  64. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  65. data/lib/insika/context_trace_store.rb +92 -0
  66. data/lib/insika/delegation_store.rb +2 -2
  67. data/lib/insika/doctor.rb +250 -5
  68. data/lib/insika/dsl/runtime.rb +12 -9
  69. data/lib/insika/dsl/server_boot.rb +4 -3
  70. data/lib/insika/dsl/system.rb +1 -1
  71. data/lib/insika/dsl.rb +72 -15
  72. data/lib/insika/edge_limiter.rb +144 -6
  73. data/lib/insika/egress_guard.rb +3 -3
  74. data/lib/insika/env_schema.rb +13 -10
  75. data/lib/insika/errors.rb +61 -5
  76. data/lib/insika/evals/assertions.rb +12 -12
  77. data/lib/insika/evals/baseline.rb +3 -3
  78. data/lib/insika/evals/golden.rb +8 -8
  79. data/lib/insika/evals/judge.rb +7 -7
  80. data/lib/insika/evals/pairwise.rb +3 -3
  81. data/lib/insika/evals/report.rb +2 -2
  82. data/lib/insika/evals/runner.rb +6 -6
  83. data/lib/insika/evals/transport.rb +2 -2
  84. data/lib/insika/event_stream.rb +23 -5
  85. data/lib/insika/executor.rb +423 -108
  86. data/lib/insika/frontmatter.rb +1 -1
  87. data/lib/insika/golden_store.rb +2 -2
  88. data/lib/insika/http_client.rb +3 -3
  89. data/lib/insika/inbound_log.rb +1 -1
  90. data/lib/insika/llm_configurator.rb +3 -3
  91. data/lib/insika/loop_detector.rb +143 -0
  92. data/lib/insika/mcp_http_client.rb +4 -4
  93. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  94. data/lib/insika/message_origin.rb +2 -2
  95. data/lib/insika/model_resolver.rb +1 -1
  96. data/lib/insika/model_selection.rb +5 -4
  97. data/lib/insika/onboarding.rb +2 -2
  98. data/lib/insika/outbox_store.rb +2 -2
  99. data/lib/insika/overlay_tool_registry.rb +3 -4
  100. data/lib/insika/pack.rb +3 -3
  101. data/lib/insika/pack_importer.rb +17 -15
  102. data/lib/insika/pending_action_store.rb +1 -1
  103. data/lib/insika/plugin/loader.rb +2 -2
  104. data/lib/insika/policy/policy.rb +1 -1
  105. data/lib/insika/profile_source.rb +12 -6
  106. data/lib/insika/provider_error_classifier.rb +160 -0
  107. data/lib/insika/queue_policy.rb +2 -2
  108. data/lib/insika/recovery.rb +47 -6
  109. data/lib/insika/refinement/candidate.rb +4 -4
  110. data/lib/insika/refinement/evidence_collector.rb +6 -6
  111. data/lib/insika/refinement/gate.rb +7 -7
  112. data/lib/insika/refinement/panel.rb +7 -7
  113. data/lib/insika/refinement/proposer.rb +9 -9
  114. data/lib/insika/refinement_store.rb +12 -12
  115. data/lib/insika/reliability.rb +185 -0
  116. data/lib/insika/safety/config.rb +2 -2
  117. data/lib/insika/safety/detectors.rb +5 -5
  118. data/lib/insika/safety/factory.rb +3 -3
  119. data/lib/insika/safety/input_guardrail.rb +19 -4
  120. data/lib/insika/safety/moderator.rb +19 -11
  121. data/lib/insika/safety/output_filter.rb +2 -2
  122. data/lib/insika/safety/output_validator.rb +2 -2
  123. data/lib/insika/safety/safe_responses.rb +1 -1
  124. data/lib/insika/sandbox/boundary.rb +2 -2
  125. data/lib/insika/sandbox.rb +1 -1
  126. data/lib/insika/server/app.rb +223 -51
  127. data/lib/insika/server/boot.rb +4 -4
  128. data/lib/insika/server/rack_app.rb +15 -7
  129. data/lib/insika/server/responses.rb +18 -8
  130. data/lib/insika/server/tenant_auth.rb +61 -0
  131. data/lib/insika/session_actor.rb +3 -3
  132. data/lib/insika/session_store.rb +1 -1
  133. data/lib/insika/settings_store.rb +5 -5
  134. data/lib/insika/shutdown.rb +4 -4
  135. data/lib/insika/skill_catalog.rb +127 -20
  136. data/lib/insika/skill_store.rb +70 -22
  137. data/lib/insika/steer_injector.rb +1 -1
  138. data/lib/insika/store.rb +1 -1
  139. data/lib/insika/studio/app.rb +183 -61
  140. data/lib/insika/studio/assets/dist/application.js +25 -24
  141. data/lib/insika/studio/forms.rb +13 -18
  142. data/lib/insika/studio/nav_icons.rb +1 -1
  143. data/lib/insika/studio/views/_message.erb +2 -2
  144. data/lib/insika/studio/views/agent_detail.erb +2 -2
  145. data/lib/insika/studio/views/agents.erb +1 -1
  146. data/lib/insika/studio/views/refinement.erb +4 -4
  147. data/lib/insika/studio/views/session.erb +78 -3
  148. data/lib/insika/studio/views/settings.erb +7 -12
  149. data/lib/insika/studio/views/skills.erb +67 -12
  150. data/lib/insika/subagent_graph.rb +3 -3
  151. data/lib/insika/task_actor.rb +3 -3
  152. data/lib/insika/task_store.rb +1 -1
  153. data/lib/insika/telemetry/pricing.rb +3 -3
  154. data/lib/insika/telemetry/recorder.rb +1 -1
  155. data/lib/insika/telemetry.rb +2 -2
  156. data/lib/insika/testing/store_contract.rb +27 -27
  157. data/lib/insika/tick.rb +122 -0
  158. data/lib/insika/token_store.rb +168 -0
  159. data/lib/insika/tool_assembly.rb +5 -5
  160. data/lib/insika/tool_definition.rb +8 -8
  161. data/lib/insika/tool_envelope.rb +1 -1
  162. data/lib/insika/tool_manifest.rb +6 -6
  163. data/lib/insika/tool_output_compressor.rb +100 -0
  164. data/lib/insika/tool_store.rb +1 -1
  165. data/lib/insika/tool_trace_store.rb +1 -1
  166. data/lib/insika/tools/concurrency.rb +2 -2
  167. data/lib/insika/tools/data_defined_tool.rb +4 -5
  168. data/lib/insika/tools/load_skill.rb +61 -3
  169. data/lib/insika/tools/stuck_signal.rb +44 -0
  170. data/lib/insika/tools/subagent.rb +4 -4
  171. data/lib/insika/tools/subagents.rb +1 -1
  172. data/lib/insika/turn_output.rb +2 -2
  173. data/lib/insika/turn_state.rb +17 -13
  174. data/lib/insika/turn_timing.rb +2 -2
  175. data/lib/insika/usage_ledger.rb +1 -1
  176. data/lib/insika/version.rb +1 -1
  177. data/lib/insika/wiring/graph.rb +77 -26
  178. data/lib/insika/workflow.rb +1 -1
  179. data/lib/insika/workflow_registry.rb +1 -1
  180. data/lib/insika.rb +32 -15
  181. metadata +19 -2
  182. data/lib/insika/server/admin_auth.rb +0 -29
@@ -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,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
@@ -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
data/lib/insika/doctor.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Insika
4
- # `insika doctor` (item 23 / §8.1 — OpenClaw's "strict config + doctor --fix",
4
+ # `insika doctor` (— OpenClaw's "strict config + doctor --fix",
5
5
  # "the repo's best productization discipline"). A read-only diagnosis of a
6
6
  # deployment's configuration that turns the scattered, silent boot warnings into
7
7
  # ONE structured report — and, with `--fix`, applies the safe autofixes.
@@ -58,12 +58,16 @@ module Insika
58
58
  end
59
59
 
60
60
  def initialize(env: ENV, settings_store: nil, llm_provider_store: nil, tool_store: nil,
61
- agent_file_store: nil, backend: nil, extra_env_specs: [])
61
+ agent_file_store: nil, skill_store: nil, skill_catalog: nil,
62
+ profile_source: nil, backend: nil, extra_env_specs: [])
62
63
  @env = env
63
64
  @settings_store = settings_store
64
65
  @llm_provider_store = llm_provider_store
65
66
  @tool_store = tool_store
66
67
  @agent_file_store = agent_file_store
68
+ @skill_store = skill_store
69
+ @skill_catalog = skill_catalog
70
+ @profile_source = profile_source
67
71
  @backend = backend
68
72
  @extra_env_specs = extra_env_specs
69
73
  end
@@ -84,7 +88,7 @@ module Insika
84
88
 
85
89
  def checks = %i[check_env check_settings_schema check_default_model check_db check_llm_provider
86
90
  check_admin_token check_data_tools check_prompt_files check_relay_channel
87
- check_web_widget]
91
+ check_web_widget check_skill_eager check_skill_drift]
88
92
 
89
93
  def safe(check)
90
94
  Array(send(check))
@@ -155,7 +159,7 @@ module Insika
155
159
  message: "ADMIN_TOKEN unset — /studio is fail-closed (login denied) and the gateway has no fallback token", fix: nil)]
156
160
  end
157
161
 
158
- # A half-configured relay is the silent kind of broken (RFC-0011 §6): with only
162
+ # A half-configured relay is the silent kind of broken: with only
159
163
  # the deliver URL set, nothing is mounted and every inbound POST 404s; with only
160
164
  # the token, the engine accepts turns it can never answer and the customer waits
161
165
  # forever on a reply that is sitting in the outbox. Both halves or neither.
@@ -176,7 +180,7 @@ module Insika
176
180
  end
177
181
 
178
182
  # The widget is the one PUBLIC channel, so its misconfigurations are the ones that
179
- # cost money rather than just failing (RFC-0011 §5.3/§5.4). Three of them, in the
183
+ # cost money rather than just failing. Three of them, in the
180
184
  # order they bite: half the switch set (mounted nowhere, or mounted addressing
181
185
  # nothing), and a mount with no chat rate limit anywhere — which the channel
182
186
  # refuses with a 503 rather than opening, but which reads to an operator as "the
@@ -202,6 +206,247 @@ module Insika
202
206
  "INSIKA_WIDGET_AGENTS sets limits.chat_rate_limit (a public channel with no ceiling is not served)", fix: nil)]
203
207
  end
204
208
 
209
+ # Eagerness moved from the SKILL.md frontmatter to `profile.skills_eager`, because
210
+ # skills are SHARED and a flag on the skill forced one decision onto every agent
211
+ # holding it. The parser now ignores `eager:` — which is the quiet kind of upgrade:
212
+ # nothing crashes, the body simply stops being in the prompt. This check is the
213
+ # only thing that says so. Its sibling half is the opposite mistake: a name in
214
+ # `skills_eager` that the agent's `skills` allowlist does not contain is
215
+ # intersected away at runtime, so the operator's intent evaporates in silence.
216
+ def check_skill_eager
217
+ findings = stale_eager_frontmatter + unreachable_eager_names
218
+ return findings if findings.any?
219
+ return [] unless @skill_store || @skill_catalog || @profile_source
220
+
221
+ [ok("skill-eager", "skill eagerness: per-agent (profile.skills_eager), no stale frontmatter")]
222
+ end
223
+
224
+ def stale_eager_frontmatter
225
+ skill_sources.filter_map do |name, content|
226
+ next unless frontmatter_of(content).key?("eager")
227
+
228
+ Finding.new(check: "skill-eager", severity: :warn, fix: nil,
229
+ message: "skill '#{name}' still declares `eager:` in its frontmatter — the key is IGNORED. " \
230
+ "Eagerness is per-agent now: put the name in that agent's `skills_eager` " \
231
+ "(Studio > Skills, or `skills_eager \"#{name}\"` in the DSL).")
232
+ end
233
+ end
234
+
235
+ def unreachable_eager_names
236
+ return [] unless @profile_source
237
+
238
+ @profile_source.all.flat_map do |profile|
239
+ eager = profile.skills_eager
240
+ next [] unless eager.is_a?(Array)
241
+ next [] if profile.skills.nil? # nil = every skill allowed, so nothing is unreachable
242
+
243
+ allowed = Array(profile.skills).map(&:to_s)
244
+ (eager.map(&:to_s) - allowed).map do |name|
245
+ Finding.new(check: "skill-eager", severity: :warn, fix: nil,
246
+ message: "agent '#{profile.id}' marks skill '#{name}' eager but does not allow it — " \
247
+ "the name is a no-op. Add it to the agent's skills, or drop it from skills_eager.")
248
+ end
249
+ end
250
+ end
251
+
252
+ # { name => raw SKILL.md } across BOTH sources the runtime reads: the disk roots
253
+ # (seed, via the catalog) and the authored store (wins — same precedence as
254
+ # SkillCatalog). The checks parse frontmatter the catalog deliberately drops
255
+ # (`eager:`), so they need the raw text; for a disk skill that is the file itself,
256
+ # and a store-overlaid skill carries a sentinel path that is not a file.
257
+ def skill_sources
258
+ @skill_sources ||= disk_skill_sources.merge(@skill_store ? @skill_store.all : {})
259
+ end
260
+
261
+ def disk_skill_sources
262
+ return {} unless @skill_catalog
263
+
264
+ @skill_catalog.all.each_with_object({}) do |skill, acc|
265
+ acc[skill.name] = File.read(skill.path, encoding: "UTF-8") if File.file?(skill.path.to_s)
266
+ end
267
+ end
268
+
269
+ # The frontmatter block only; a `eager:` line in the BODY is prose, not config.
270
+ def frontmatter_of(content)
271
+ match = content.to_s.match(/\A---\s*\n(.*?)\n---\s*\n/m)
272
+ match ? Insika::Frontmatter.parse(match[1]) : {}
273
+ end
274
+
275
+ # Drift between a pack's PROSE and the catalog. Three ways it happened on the
276
+ # pilot, all silent, all found by reading a customer conversation afterwards.
277
+ #
278
+ # Every check here takes MECHANICAL inputs only — skill names, allowlists, agent
279
+ # identities. A check that has to parse prose ("this paragraph declares a count of
280
+ # six") false-positives on the first real pack and takes the doctor's credibility
281
+ # with it, which costs more than the drift it caught.
282
+ def check_skill_drift
283
+ return [] unless (@skill_store || @skill_catalog) && @profile_source
284
+
285
+ findings = prompt_files_naming_unallowed_skills + shared_skills_naming_a_holder + broken_companions
286
+ return findings if findings.any?
287
+
288
+ [ok("skill-drift", "skill references: prompt files, shared bodies and companions all consistent")]
289
+ end
290
+
291
+ # D1 residue. The routing table is GENERATED now (SkillCatalog#format_for_prompt
292
+ # renders each skill with its triggers), so the hand-written companion has no
293
+ # reason to exist — but a pack that still carries one keeps instructing the model
294
+ # about skills the agent cannot load. Skill names are known ids, so this is a grep.
295
+ def prompt_files_naming_unallowed_skills
296
+ return [] unless @agent_file_store
297
+
298
+ catalog = skill_sources.keys
299
+ @profile_source.all.flat_map do |profile|
300
+ next [] if profile.skills.nil? # nil = everything allowed, nothing to be outside of
301
+
302
+ allowed = Array(profile.skills).map(&:to_s)
303
+ orphans = catalog - allowed
304
+ next [] if orphans.empty?
305
+
306
+ @agent_file_store.list(profile.id).flat_map do |file|
307
+ body = @agent_file_store.read(profile.id, file).to_s
308
+ orphans.select { |name| mentions?(body, name) }.map do |name|
309
+ Finding.new(check: "skill-drift", severity: :warn, fix: nil,
310
+ message: "agent '#{profile.id}' file '#{file}' names skill '#{name}', which is NOT in its " \
311
+ "skills allowlist — the model is being told to use something it cannot load. " \
312
+ "Allow the skill, or drop the reference (the skill table is generated).")
313
+ end
314
+ end
315
+ end
316
+ end
317
+
318
+ # D2. A skill in more than one allowlist that names one of its OWN holders in its
319
+ # text is specialized text in shared clothing — the pilot served the Cacau Show
320
+ # agent three shared skills that each said "na Natura". Specialize it per agent
321
+ # (write_skill with `agent:`) instead of leaving one store's policy in a shared body.
322
+ #
323
+ # Merchant vocabulary beyond the holders' identities is deliberately out of scope:
324
+ # there is no mechanical source for it.
325
+ def shared_skills_naming_a_holder
326
+ holders = skill_holders
327
+ specialized = specialized_by
328
+ skill_sources.flat_map do |name, content|
329
+ owners = holders[name].to_a
330
+ next [] if owners.length < 2
331
+
332
+ # Who still READS this shared body: a holder with its own version reads that
333
+ # instead, so it stops being a victim — but its identity in the shared text
334
+ # keeps poisoning whoever is left. Identity comes from ALL holders; only the
335
+ # readers shrink as specializations land, so the finding clears when the last
336
+ # victim stops reading, never merely because the named holder moved out.
337
+ readers = owners - specialized[name].to_a
338
+ owners.flat_map do |owner|
339
+ victims = (readers - [owner]).sort
340
+ next [] if victims.empty?
341
+
342
+ identity_terms(owner).select { |term| mentions?(content, term) }.map do |term|
343
+ Finding.new(check: "skill-drift", severity: :warn, fix: nil,
344
+ message: "shared skill '#{name}' names '#{term}', the identity of '#{owner}' — " \
345
+ "#{victims.join(', ')} read(s) #{owner}'s text as their own. Specialize " \
346
+ "'#{owner}' if it needs that text, and remove it from the shared body.")
347
+ end
348
+ end
349
+ end
350
+ end
351
+
352
+ # D3 residue, the half `companions:` cannot prevent: a body that points at another
353
+ # catalog skill without declaring it (so the pair can still break apart), and a
354
+ # declared companion an agent is not allowed to load (so the pair breaks for THAT
355
+ # agent, silently, since the engine will not widen an allowlist on its own).
356
+ def broken_companions
357
+ names = skill_sources.keys
358
+ undeclared = skill_sources.flat_map do |name, content|
359
+ declared = declared_companions(content)
360
+ (names - [name] - declared).select { |other| mentions?(body_of(content), other) }.map do |other|
361
+ Finding.new(check: "skill-drift", severity: :warn, fix: nil,
362
+ message: "skill '#{name}' references skill '#{other}' in its body without declaring it a " \
363
+ "companion — the two can arrive apart, and half a recipe is worse than none. " \
364
+ "Add `companions: [#{other}]` to '#{name}'.")
365
+ end
366
+ end
367
+ undeclared + companions_outside_allowlists
368
+ end
369
+
370
+ # Same lenient reading as SkillCatalog#parse_list: a YAML list, or the whole value
371
+ # as one comma-separated String when the tolerant parser had to fall back.
372
+ def declared_companions(content)
373
+ raw = frontmatter_of(content)["companions"]
374
+ Array(raw).flat_map { |c| c.to_s.split(",") }.map(&:strip).reject(&:empty?)
375
+ end
376
+
377
+ def companions_outside_allowlists
378
+ declared = skill_sources.each_with_object({}) do |(name, content), acc|
379
+ list = declared_companions(content)
380
+ acc[name] = list unless list.empty?
381
+ end
382
+ return [] if declared.empty?
383
+
384
+ @profile_source.all.flat_map do |profile|
385
+ next [] if profile.skills.nil?
386
+
387
+ allowed = Array(profile.skills).map(&:to_s)
388
+ declared.flat_map do |name, companions|
389
+ next [] unless allowed.include?(name)
390
+
391
+ (companions - allowed).map do |missing|
392
+ Finding.new(check: "skill-drift", severity: :warn, fix: nil,
393
+ message: "agent '#{profile.id}' allows skill '#{name}' but not its companion '#{missing}' — " \
394
+ "the pair cannot travel together for this agent. Allow '#{missing}' too.")
395
+ end
396
+ end
397
+ end
398
+ end
399
+
400
+ # { skill name => [agent ids that have their OWN version] }.
401
+ def specialized_by
402
+ return {} unless @skill_store.respond_to?(:agents)
403
+
404
+ @skill_store.agents.each_with_object({}) do |agent, acc|
405
+ @skill_store.names(agent: agent).each { |name| (acc[name] ||= []) << agent }
406
+ end
407
+ end
408
+
409
+ # { skill name => Set(agent ids that allow it explicitly) }. An agent with
410
+ # skills=nil allows everything and is not a "holder": it says nothing about which
411
+ # skills were meant to be shared.
412
+ def skill_holders
413
+ @profile_source.all.each_with_object({}) do |profile, acc|
414
+ next if profile.skills.nil?
415
+
416
+ Array(profile.skills).each { |name| (acc[name.to_s] ||= []) << profile.id }
417
+ end
418
+ end
419
+
420
+ # An agent's identity as WORDS: the distinctive tokens of its id plus whatever it
421
+ # calls itself in metadata. Structural tokens (agent/store/bot/…) are dropped and
422
+ # short ones ignored — "store" appears in every retail skill ever written, and one
423
+ # false positive is enough for an operator to stop reading the doctor.
424
+ IDENTITY_STOPWORDS = %w[agent agente store shop loja bot assistant assistente atendimento
425
+ prod staging demo test main default].freeze
426
+
427
+ def identity_terms(agent_id)
428
+ profile = @profile_source.fetch(agent_id)
429
+ meta = profile ? (profile.metadata || {}) : {}
430
+ raw = [agent_id.to_s.split(/[-_.\s]+/), meta["name"], meta["display_name"], meta["store_name"]]
431
+ raw.flatten.compact.map { |t| t.to_s.strip }
432
+ .reject { |t| t.length < 4 || IDENTITY_STOPWORDS.include?(t.downcase) }
433
+ .uniq
434
+ end
435
+
436
+ # Whole-word, case- and accent-insensitive — the same reading the trigger matcher
437
+ # uses, for the same reason: a substring hit inside a longer word is a false
438
+ # positive, and one of those is enough to lose the operator.
439
+ def mentions?(text, term)
440
+ needle = fold(term)
441
+ return false if needle.empty?
442
+
443
+ /(?<![[:alnum:]])#{Regexp.escape(needle)}(?![[:alnum:]])/.match?(fold(text))
444
+ end
445
+
446
+ def fold(text) = text.to_s.unicode_normalize(:nfd).gsub(/\p{Mn}/, "").downcase
447
+
448
+ def body_of(content) = content.to_s.sub(/\A---\s*\n.*?\n---\s*\n/m, "")
449
+
205
450
  def platform_chat_rate_limit
206
451
  value = ((@settings_store&.get || {})["edge"] || {})["chat_rate_limit"]
207
452
  value.to_i.positive?
@@ -26,16 +26,16 @@ module Insika
26
26
  # the runtime does not care how many agents it hosts, only that each one
27
27
  # arrives as an ordinary Pack.
28
28
  #
29
- # `backend`: the store this graph owns (RFC-0017 A1). nil = the historic
29
+ # `backend`: the store this graph owns. nil = the historic
30
30
  # path, `INSIKA_DB` or memory — ENV is a default, never a requirement (the
31
- # embed contract, item 5). An injected backend wins and is never widened
31
+ # embed contract). An injected backend wins and is never widened
32
32
  # back to the environment.
33
33
  def initialize(definition, backend: nil)
34
34
  @definition = definition
35
35
  @injected_backend = backend
36
36
  @packs = (definition.respond_to?(:packs) ? Array(definition.packs) : [definition.pack]).freeze
37
37
  @pack = @packs.first
38
- # RFC-0017 A2: the graph's OWN RubyLLM config, built before the graph so
38
+ # the graph's OWN RubyLLM config, built before the graph so
39
39
  # every collaborator that talks to a provider is handed it at wiring time
40
40
  # rather than reading a process-wide singleton at call time.
41
41
  @llm = build_llm_context
@@ -44,7 +44,7 @@ module Insika
44
44
  import_packs
45
45
  end
46
46
 
47
- # The graph's RubyLLM context (RFC-0017 A2) — an isolated config dup that
47
+ # The graph's RubyLLM context — an isolated config dup that
48
48
  # answers #chat. nil only under the test stub, where the fallback is the
49
49
  # RubyLLM constant itself. Read by the specs and by anything a host wires
50
50
  # alongside this graph.
@@ -178,7 +178,7 @@ module Insika
178
178
  guardrails: c[:guardrails], context_providers: context_providers(spine, c),
179
179
  edge_limiter: c[:edge_limiter],
180
180
  executor_extra: { settings_store: c[:settings_store], tool_trace_store: c[:tool_trace_store],
181
- llm: @llm }
181
+ context_trace_store: c[:context_trace_store], llm: @llm }
182
182
  )
183
183
  register_authoring_commands(graph, c)
184
184
  register_workflows(graph)
@@ -228,17 +228,19 @@ module Insika
228
228
  system_file_store: Insika::SystemFileStore.new(config_store: config_store),
229
229
  mcp_store: Insika::McpStore.new(config_store: config_store),
230
230
  tool_trace_store: Insika::ToolTraceStore.new(store: backend),
231
+ context_trace_store: Insika::ContextTraceStore.new(store: backend),
231
232
  tool_registry: tool_registry,
232
233
  tool_catalog: Insika::ToolCatalog.new(tool_registry: tool_registry),
233
234
  skill_catalog: Insika::SkillCatalog.new([], store: skill_store),
234
235
  prompt_catalog: Insika::PromptCatalog.new([]),
235
236
  profile_source: Insika::StoredProfileSource.new(config_store: config_store),
236
- # A2: the moderator/validator tiers ask a model too. A graph reading its
237
+ # the moderator/validator tiers ask a model too. A graph reading its
237
238
  # own key for the turn but the global one for a guardrail would be a
238
239
  # credential leak wearing the look of isolation.
239
240
  guardrails: Insika::Safety::Factory.new(settings_store: settings_store, llm: @llm),
240
241
  edge_limiter: Insika::EdgeLimiter.new(
241
- ledger: Insika::UsageLedger.new(store: backend), settings_store: settings_store
242
+ ledger: Insika::UsageLedger.new(store: backend), settings_store: settings_store,
243
+ budget_ledger: spine.budget_ledger, event_stream: spine.event_stream
242
244
  )
243
245
  }
244
246
  end
@@ -251,6 +253,7 @@ module Insika
251
253
  agent_files: c[:agent_file_store], system_files: c[:system_file_store]
252
254
  ),
253
255
  Insika::Context::Providers::Skill.new(catalog: c[:skill_catalog]),
256
+ Insika::Context::Providers::SkillTrigger.new(catalog: c[:skill_catalog]),
254
257
  Insika::Context::Providers::ToolSearch.new(catalog: c[:tool_catalog]),
255
258
  Insika::Context::Providers::Memory.new(store: spine.memory_store),
256
259
  Insika::Context::Providers::Session.new(session_store: spine.session_store)
@@ -303,7 +306,7 @@ module Insika
303
306
  ([provider_name] + @packs.filter_map { |p| Insika::Coercion.presence(p.config[:provider])&.to_s }).uniq
304
307
  end
305
308
 
306
- # RFC-0017 A2 — the graph's credentials belong to the graph. `RubyLLM.context`
309
+ # the graph's credentials belong to the graph. `RubyLLM.context`
307
310
  # is an isolated dup of the config that answers #chat, so two graphs in one
308
311
  # process no longer overwrite each other's keys: the global
309
312
  # `RubyLLM.configure` is a single slot PER PROVIDER, and the loser of that
@@ -332,7 +335,7 @@ module Insika
332
335
 
333
336
  # The LLMConfigurator's target (Studio: edit a provider key, no restart).
334
337
  # Scoped to THIS graph's context — a `RubyLLM.configure` here would reach
335
- # into every other graph in the process, which is the leak A2 exists to
338
+ # into every other graph in the process, which is the leak exists to
336
339
  # close. nil = no context (test stub) -> the configurator's own global
337
340
  # default, unchanged.
338
341
  def llm_configure
@@ -25,7 +25,7 @@ module Insika
25
25
  # Fixed local token: logs into the Studio (cookie) and gates /v1 (Bearer).
26
26
  # Never a real secret — override with `token:` or ADMIN_TOKEN.
27
27
  @token = token || ENV.fetch("ADMIN_TOKEN", "local-demo")
28
- # RFC-0017 A3: the /v1 app is a value now — the same one a host mounts.
28
+ # the /v1 app is a value now — the same one a host mounts.
29
29
  # This boot adds the Studio and a reactor around it, nothing else.
30
30
  @builder = Insika::Server::AppBuilder.new(runtime, token: @token)
31
31
  end
@@ -35,13 +35,13 @@ module Insika
35
35
  dispatch = Rack::URLMap.new("/studio" => Studio::App, "/" => @builder.app)
36
36
  endpoint = Async::HTTP::Endpoint.parse("http://#{@host}:#{@port}")
37
37
  middleware = Protocol::Rack::Adapter.new(dispatch)
38
- # Item 16 / P4: the OTEL bridge is opt-in but must be reachable from the DSL
38
+ # the OTEL bridge is opt-in but must be reachable from the DSL
39
39
  # front door too — the convention is worthless if only config.ru can export.
40
40
  # nil (the default) -> attach is a no-op and no gem is loaded.
41
41
  telemetry = Insika::Telemetry.setup(service_name: ENV.fetch("OTEL_SERVICE_NAME", "insika"))
42
42
 
43
43
  banner(telemetry)
44
- # RFC-0016 A3: first Ctrl-C/SIGTERM closes the intake and drains in-flight
44
+ # first Ctrl-C/SIGTERM closes the intake and drains in-flight
45
45
  # turns (INSIKA_DRAIN_TIMEOUT, default 20s); a second signal skips the wait.
46
46
  Insika::Shutdown.install(executor: @graph.executor)
47
47
  Async do
@@ -68,6 +68,7 @@ module Insika
68
68
  settings_store: @rt.component(:settings_store), llm_provider_store: @rt.component(:provider_store),
69
69
  mcp_store: @rt.component(:mcp_store), system_file_store: @rt.component(:system_file_store),
70
70
  tool_trace_store: @rt.component(:tool_trace_store),
71
+ context_trace_store: @rt.component(:context_trace_store),
71
72
  task_store: @graph.task_store, checkpoint_store: @graph.checkpoint_store,
72
73
  pending_action_store: @graph.pending_action_store
73
74
  )
@@ -17,7 +17,7 @@ module Insika
17
17
  class System
18
18
  attr_reader :definitions, :workflows, :runtime_options
19
19
 
20
- # `backend`: the store this system's graph owns (RFC-0017 A1). nil = the
20
+ # `backend`: the store this system's graph owns. nil = the
21
21
  # historic path (INSIKA_DB, or memory when unset). Set by `Insika.embed`.
22
22
  def initialize(definitions:, workflows: [], runtime: {}, backend: nil)
23
23
  @definitions = definitions.freeze