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
@@ -4,9 +4,8 @@ require "json"
4
4
 
5
5
  module Insika
6
6
  module Server
7
- # OpenAI Responses edge adapter (`/v1/responses`) — the contract that the
8
- # OpenClaw gateway consumers already speak (see achei-b2b
9
- # `CoreServices::OpenclawDispatcher`). Phase 6, Step A.
7
+ # OpenAI Responses edge adapter (`/v1/responses`) — the contract that
8
+ # OpenClaw gateway consumers already speak.
10
9
  #
11
10
  # PURE module (no state, no framework): (a) translates the OpenAI
12
11
  # Responses request → `:send_message` payload; (b) maps each turn Event →
@@ -26,7 +25,7 @@ module Insika
26
25
  # `origin` is the consumer declaring WHO wrote the input it is sending. It
27
26
  # matters here more than anywhere: this adapter's `input` is a STRING the
28
27
  # consumer already composed out of context blocks plus the customer's text
29
- # (`<memoria> …`, `<cacau_cep_obrigatorio> …`), so a transcript reader cannot
28
+ # (`<memoria> …`, `<store_cep_required> …`), so a transcript reader cannot
30
29
  # tell the two apart — the first refinement run over real traffic reported 219
31
30
  # "the customer repeated themselves" that were the engine reading its own
32
31
  # fragment back. A consumer that sends `origin: "engine"` on a composed turn
@@ -84,8 +83,8 @@ module Insika
84
83
  # The provider's reasoning. Internal unless the AGENT opted in
85
84
  # (`edge_stream thinking: true`), which tags the event. Even then it does
86
85
  # NOT become answer text: it gets the Responses reasoning frame, so a
87
- # consumer that only accumulates `output_text` deltas — achei-b2b's
88
- # dispatcher, which turns them into one WhatsApp message — is unaffected,
86
+ # consumer that only accumulates `output_text` deltas — a dispatcher
87
+ # that turns them into one WhatsApp message — is unaffected,
89
88
  # and one that renders reasoning has something to render.
90
89
  if public_delta(event)
91
90
  sse("response.reasoning_summary_text.delta",
@@ -108,13 +107,19 @@ module Insika
108
107
  { type: "insika.intermediate.delta", delta: event.data[:delta].to_s })
109
108
  end
110
109
  when :guardrail_blocked, :guardrail_flagged
111
- # RFC-0009: audit events with no OpenAI Responses counterpart. On a BLOCK
110
+ # audit events with no OpenAI Responses counterpart. On a BLOCK
112
111
  # the safe reply still reaches the consumer through the normal :content
113
112
  # deltas + :task_completed path (the turn completes gracefully), so there
114
113
  # is nothing extra to translate here — the events live in /v1/events + the
115
114
  # Studio + the trace. Explicit (not a fall-through) to keep the closed
116
115
  # catalog honest.
117
116
  nil
117
+ when :ttft
118
+ # the live TTFB signal (WS6, INSIKA_TURN_TIMING opt-in): the provider's
119
+ # ms-to-first-token, emitted when the first content chunk arrives.
120
+ # Namespaced insika.* — no OpenAI Responses counterpart; unknown types
121
+ # are ignored, the safe failure.
122
+ sse("insika.ttft", { type: "insika.ttft", ttft_ms: event.data[:ttft_ms].to_i })
118
123
  end
119
124
  end
120
125
 
@@ -133,9 +138,14 @@ module Insika
133
138
  response[:usage] = usage.reject { |k, _| k.to_s == "model" }
134
139
  response[:model] = model if model
135
140
  end
136
- # Opt-in per-turn latency breakdown (INSIKA_TURN_TIMING; item 34). Absent
141
+ # Opt-in per-turn latency breakdown (INSIKA_TURN_TIMING). Absent
137
142
  # by default — a non-standard sibling used only for TTFB diagnostics.
138
143
  (timing = event.data[:timing]) && (response[:timing] = timing)
144
+ # WS5 stuck signal: an additive sibling the terminal frame carries when the
145
+ # agent ended the turn declaring it cannot proceed. Consumers that only read
146
+ # the OpenAI-shaped response.use it to run their escalation ("stuck" means
147
+ # what they decide it means, never the engine's business).
148
+ (outcome = event.data[:outcome]) && (response[:outcome] = outcome.to_s)
139
149
  sse("response.completed", { type: "response.completed", response: response })
140
150
  end
141
151
 
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "rack/utils"
5
+
6
+ module Insika
7
+ module Server
8
+ # Edge resolution for WS1 (multi-tenant): `Authorization: Bearer <token>` ->
9
+ # a principal `{ role:, tenant_id: }`, resolved BEFORE the routes. Two modes,
10
+ # one gate:
11
+ #
12
+ # single_tenant (default) — no token store: the classic single operator
13
+ # credential (config[:gateway_token]) is the only thing that resolves.
14
+ # multi_tenant — tokens live in the TokenStore (per-tenant + operator).
15
+ # A configured gateway_token STILL resolves as operator (an existing
16
+ # deployment switching modes keeps its credential — additive, never
17
+ # a second-class path).
18
+ #
19
+ # Fail-closed BY CONSTRUCTION: no store and no configured token -> :disabled
20
+ # (503, never open). A revoked or unknown token -> :unauthorized. Pure module,
21
+ # testable without a Rack env.
22
+ module TenantAuth
23
+ module_function
24
+
25
+ # gateway_token: config[:gateway_token] | nil. token_store: TokenStore |
26
+ # nil. header: raw Authorization value.
27
+ # -> :disabled | :unauthorized | { role: "operator"|"tenant", tenant_id: }
28
+ def check(gateway_token, token_store, header)
29
+ # Fail-closed FIRST (the construction rule): with no store AND no
30
+ # configured token the gateway is DISABLED (503) however the request
31
+ # looks — never "401: who are you facing a door that does not exist".
32
+ # A token_store present means the gateway IS configured (multi_tenant),
33
+ # with or without the legacy gateway token.
34
+ return :disabled if token_store.nil? && (gateway_token.nil? || gateway_token.empty?)
35
+
36
+ provided = header.to_s[/\ABearer (.+)\z/, 1]
37
+ return :unauthorized if provided.nil?
38
+
39
+ if token_store
40
+ record = token_store.resolve(provided)
41
+ unless record
42
+ # store miss -> the legacy gateway token still resolves as operator
43
+ # (an existing deployment switching modes keeps its credential).
44
+ return :unauthorized if gateway_token.nil? || gateway_token.empty?
45
+ return :unauthorized unless Rack::Utils.secure_compare(gateway_token, provided)
46
+
47
+ return { role: "operator", tenant_id: nil }
48
+ end
49
+
50
+ return { role: record.role.to_s, tenant_id: record.tenant_id }
51
+ end
52
+
53
+ # classic mode (no store): the gateway token is the only credential.
54
+ # Constant-time comparison: the operator token doesn't leak via timing.
55
+ return :unauthorized unless Rack::Utils.secure_compare(gateway_token, provided)
56
+
57
+ { role: "operator", tenant_id: nil }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -17,7 +17,7 @@ module Insika
17
17
  # Executor) is also born on the supervisor; the SessionActor only AWAITS it to
18
18
  # serialize.
19
19
  #
20
- # RFC-0015: it is also where an inbound message for a BUSY session is routed.
20
+ # it is also where an inbound message for a BUSY session is routed.
21
21
  # That decision belongs here and nowhere else — this is already the object that
22
22
  # owns "one turn at a time for this session". Putting it in the HTTP handler
23
23
  # would duplicate the invariant; putting it in the Executor would mix turn
@@ -45,7 +45,7 @@ module Insika
45
45
  task.id
46
46
  end
47
47
 
48
- # RFC-0015 §5.3 — merge a fragment into the turn waiting at the door.
48
+ # merge a fragment into the turn waiting at the door.
49
49
  # -> the task id it joined, or nil when there is nothing to merge into (no
50
50
  # pending turn, the window has closed, or the turn already started). nil is
51
51
  # the caller's signal to create a task of its own.
@@ -117,7 +117,7 @@ module Insika
117
117
  end
118
118
  end
119
119
 
120
- # RFC-0015 §5.3 — the debounce window. Sleeps on the LOOP's fiber, never on the
120
+ # the debounce window. Sleeps on the LOOP's fiber, never on the
121
121
  # request's, so the POST is acked immediately and the platform does not retry.
122
122
  # Returns the task to run (re-read from the store when fragments merged into it,
123
123
  # since the in-memory Task is a frozen snapshot of an older message).
@@ -61,7 +61,7 @@ module Insika
61
61
  # fiber, without a lock. Each message gets an "at" (ISO8601 UTC) if not
62
62
  # provided. NotFoundError if the session does not exist.
63
63
  #
64
- # CONCURRENCY LIMITATION (§11 R2c): the RMW (read record -> += -> set) is
64
+ # CONCURRENCY LIMITATION (R2c): the RMW (read record -> += -> set) is
65
65
  # atomic ONLY because the SessionActor serializes turns of the same session
66
66
  # (one owner at a time). That serialization exists solely in SUPERVISED mode
67
67
  # (the actor loop lives on the supervisor). Two concurrent send_message on the
@@ -13,7 +13,7 @@ module Insika
13
13
  SCOPE = "settings"
14
14
  KEY = "general"
15
15
 
16
- # STRICT config, settings layer (item 23 / §8.1 — "no silent config compat: every
16
+ # STRICT config, settings layer (— "no silent config compat: every
17
17
  # schema migration explicit"). The settings record carries a `schema_version`;
18
18
  # every shape change is a numbered migration here, applied ONLY by the explicit
19
19
  # `migrate!` (Studio settings saves never silently reinterpret old-shaped data).
@@ -31,7 +31,7 @@ module Insika
31
31
  "turn_timeout" => 120,
32
32
  "tool_timeout" => 30,
33
33
  "compaction" => { "enabled" => false, "keep_last" => 20 },
34
- # LLM config v2 (§10). Platform-wide model layer, resolved by the
34
+ # LLM config v2. Platform-wide model layer, resolved by the
35
35
  # ModelResolver under an agent that pins no model of its own:
36
36
  # default_model/default_provider -> the platform default (Chat > Agent > HERE)
37
37
  # fallback_models -> ordered chain ["provider/model" | "model", ...] tried
@@ -42,13 +42,13 @@ module Insika
42
42
  "default_provider" => nil,
43
43
  "fallback_models" => [],
44
44
  "utility_model" => nil,
45
- # Reasoning control (§10, 4-layer: Chat > Agent > Model > Global). `thinking`
45
+ # Reasoning control (4-layer: Chat > Agent > Model > Global). `thinking`
46
46
  # is the GLOBAL default (off/on/low/medium/high; nil = provider default);
47
47
  # `model_params` is the PER-MODEL layer, a map "<provider/model>"|"<model>" ->
48
48
  # { "thinking" => ... }. Both resolved by the ModelResolver.
49
49
  "thinking" => nil,
50
50
  "model_params" => {},
51
- # Evals (RFC-0008, panel by RFC-0013 §3.9). The GRADERS are platform config, so
51
+ # Evals (panel by). The GRADERS are platform config, so
52
52
  # the operator picks them in the Studio instead of remembering a CLI flag:
53
53
  # judges -> [{ "model" =>, "provider" => }, …]. [] = deterministic
54
54
  # asserts only (rubric'd cases read as judge_pending).
@@ -66,7 +66,7 @@ module Insika
66
66
  "quorum" => 1,
67
67
  "tolerance" => 0.05
68
68
  },
69
- # Edge limits (item 33 / §12 G7) — the platform layer of the EdgeLimiter.
69
+ # Edge limits — the platform layer of the EdgeLimiter.
70
70
  # nil/0 = off (opt-in). chat_rate_limit = turn attempts per chat per
71
71
  # chat_rate_window (s); agent_token_ceiling = total tokens per agent per
72
72
  # agent_token_window (s). limit_response overrides the safe reply.
@@ -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,93 @@ 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
+ # `agent` (an agent id) resolves the AGENT SCOPE first, then the shared one — the
38
+ # same precedence chain the catalog already runs for store-over-disk and
39
+ # workspace-over-managed-over-bundled, with one more dimension.
40
+ #
41
+ # Three cases fall out of that one rule: SHARED (only the shared record exists),
42
+ # OVERRIDE (both exist, the agent's wins) and AGENT-PRIVATE (only the agent record
43
+ # exists — invisible elsewhere, and its name may collide freely).
44
+ #
45
+ # Without `agent` the shared scope is all there is, which is what every caller
46
+ # that has no agent in hand (the Studio's shared editor, a bare catalog) means.
47
+ def all(agent: nil)
48
+ shared = @skills
49
+ overrides = agent_scope(agent)
50
+ return shared.values if overrides.empty?
51
+
52
+ shared.merge(overrides).values
30
53
  end
31
54
 
32
- def find(name)
33
- @skills[name.to_s]
55
+ def find(name, agent: nil)
56
+ agent_scope(agent)[name.to_s] || @skills[name.to_s]
34
57
  end
35
58
 
36
59
  # Reloads from disk + Store and SWAPS the index atomically: an
37
60
  # authored/edited skill takes effect without a restart. A turn in progress
38
61
  # captured @skills at dispatch, so it does not see the swap mid-flight.
39
62
  def reload
40
- @skills = load_all
63
+ @skills, @agent_skills = load_all
41
64
  self
42
65
  end
43
66
 
44
- # Per-agent allowlist: nil -> all | [] -> none | [names] -> subset.
45
- def effective(skills_policy)
46
- Allowlist.filter(all, skills_policy) { |s| s.name }
67
+ # Per-agent allowlist: nil -> all | [] -> none | [names] -> subset. `agent`
68
+ # selects WHICH body each allowed name resolves to (see #find); the allowlist is
69
+ # by NAME either way, so specializing a skill never touches the allowlist.
70
+ def effective(skills_policy, agent: nil)
71
+ Allowlist.filter(all(agent: agent), skills_policy) { |s| s.name }
47
72
  end
48
73
 
74
+ # THE single definition of "always in the prompt", consulted by all three
75
+ # surfaces that must agree: the body provider (injects these), the level-1
76
+ # catalog (hides them) and load_skill (refuses them). Split the rule across three
77
+ # files and they drift — which is the failure this whole feature came from.
78
+ #
79
+ # `profile.skills_eager` — a PER-AGENT decision, so a shared skill stays shared:
80
+ # nil | false -> none (progressive disclosure; the default)
81
+ # true -> every allowed skill (blanket; only for a corpus that fits the budget)
82
+ # [names] -> exactly these
83
+ #
84
+ # Deliberately NOT `Allowlist.filter`: there nil means ALL, which is the safe
85
+ # default for `skills`/`tools_allow` where nil is "no policy". Here nil must mean
86
+ # NONE — an unconfigured agent waking up with every skill body on every turn is
87
+ # the opposite of a safe default. A name that is not in the agent's `skills`
88
+ # allowlist is a silent no-op here (the intersection with `effective`); `doctor`
89
+ # flags it, because the operator who wrote the name meant it.
90
+ def eager_for(profile)
91
+ allowed = effective(profile.skills, agent: profile.id)
92
+ spec = profile.skills_eager
93
+ return allowed if blanket?(spec)
94
+ return [] if spec.nil? || spec == false
95
+
96
+ names = Array(spec).map { |n| n.to_s.strip }
97
+ allowed.select { |s| names.include?(s.name) }
98
+ end
99
+
100
+ # The complement: what the model still has to ASK for — and therefore what the
101
+ # level-1 list advertises and load_skill will serve.
102
+ def lazy_for(profile) = effective(profile.skills, agent: profile.id) - eager_for(profile)
103
+
49
104
  # Level 1: compact list injected into the system prompt. Metadata only.
50
105
  # Receives the set already filtered by the agent.
106
+ #
107
+ # `when=` carries the skill's `triggers:` — THE ROUTING TABLE, GENERATED. What
108
+ # actually made activation reliable on the pilot was a hand-written companion file
109
+ # listing each skill with its trigger phrases, and nothing checked it against the
110
+ # catalog: a skill created at 11:28 was invisible to a table written the day
111
+ # before, and the model obeyed the table. Rendering the same information from the
112
+ # catalog means it cannot disagree with the allowlist — a newly allowed skill
113
+ # appears the moment it is allowed. Detecting that drift would have been strictly
114
+ # worse than removing its source.
51
115
  def format_for_prompt(skills = all)
52
116
  return "" if skills.empty?
53
117
 
54
118
  entries = skills.map do |s|
55
- %( <skill name="#{s.name}">#{s.description}</skill>)
119
+ when_attr = Array(s.triggers).empty? ? "" : %( when="#{Array(s.triggers).join('; ')}")
120
+ %( <skill name="#{s.name}"#{when_attr}>#{s.description}</skill>)
56
121
  end.join("\n")
57
122
 
58
123
  <<~PROMPT.strip
@@ -60,13 +125,24 @@ module Insika
60
125
  #{entries}
61
126
  </available_skills>
62
127
 
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.
128
+ Before ANY reply or tool call: scan the skills above. If one matches
129
+ or is even partially relevant to the task, you MUST call
130
+ `load_skill("name")` FIRST and follow what it returns. Err on the
131
+ side of loading. Only skip when genuinely none apply.
65
132
  PROMPT
66
133
  end
67
134
 
68
135
  private
69
136
 
137
+ # The blanket switch, tolerant of the strings a form / JSON round-trip produces
138
+ # ("1" from a checkbox, "true" from a pack) — same reading as
139
+ # AgentProfile#stream_public?. Anything else (a list, nil, false) is not blanket.
140
+ def blanket?(spec) = Coercion.truthy?(spec)
141
+
142
+ # An agent's override index; {} for a nil agent or one that specialized nothing.
143
+ def agent_scope(agent) = agent.nil? ? {} : (@agent_skills[agent.to_s] || {})
144
+
145
+ # -> [shared index, { agent_id => index }].
70
146
  def load_all
71
147
  found = {}
72
148
  @roots.each do |root|
@@ -78,7 +154,7 @@ module Insika
78
154
  end
79
155
  end
80
156
  overlay_store(found)
81
- found
157
+ [found, load_agent_scopes]
82
158
  end
83
159
 
84
160
  # Store skills overlay the on-disk ones (authored > seed). Sentinel path
@@ -87,27 +163,58 @@ module Insika
87
163
  return unless @store
88
164
 
89
165
  @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
166
+ skill = parse_content(content.to_s, path: "store:#{name}", key: name)
167
+ found[name.to_s] = skill if skill # Store wins
168
+ end
169
+ end
170
+
171
+ # Per-agent overrides / private skills, one index per agent. A store that predates
172
+ # the agent dimension answers nothing here, so this is {} and every lookup falls
173
+ # straight through to the shared scope.
174
+ def load_agent_scopes
175
+ return {} unless @store.respond_to?(:agents)
176
+
177
+ @store.agents.each_with_object({}) do |agent, acc|
178
+ index = {}
179
+ @store.all(agent: agent).each do |name, content|
180
+ skill = parse_content(content.to_s, path: "store:#{agent}/#{name}", key: name)
181
+ index[name.to_s] = skill if skill
182
+ end
183
+ acc[agent.to_s] = index unless index.empty?
92
184
  end
93
185
  end
94
186
 
95
- def parse_content(raw, path:)
187
+ # `key` = THE STORE POSITION, and it wins over the frontmatter `name:`. An override
188
+ # authored for one agent still says `name: escalation-to-human` inside — that is
189
+ # deliberate, it is the same skill specialized — and indexing by the parsed name
190
+ # would clobber the shared record globally, which is the exact bug the agent scope
191
+ # exists to fix. It also makes a pack whose directory name and frontmatter name
192
+ # disagree resolvable: the allowlist is written from the directory.
193
+ def parse_content(raw, path:, key: nil)
96
194
  match = raw.match(/\A---\s*\n(.*?)\n---\s*\n(.*)\z/m)
97
195
  return nil unless match
98
196
 
99
197
  # Tolerant frontmatter: real packs have `: ` in the description prose, which
100
198
  # strict YAML rejected (the pack would not load).
101
199
  meta = Insika::Frontmatter.parse(match[1])
102
- name = meta["name"]
103
- return nil unless name
200
+ name = key || meta["name"]
201
+ return nil unless name && Coercion.present?(meta["name"])
104
202
 
105
203
  Skill.new(
106
204
  name: name.to_s,
107
205
  description: meta["description"].to_s,
108
206
  path: path,
109
- body: match[2].strip
207
+ body: match[2].strip,
208
+ triggers: parse_list(meta["triggers"]),
209
+ companions: parse_list(meta["companions"])
110
210
  )
111
211
  end
212
+
213
+ # `triggers:` / `companions:` frontmatter. YAML list, or comma-separated string
214
+ # under the lenient parse (which yields the whole value as one String).
215
+ def parse_list(raw)
216
+ list = raw.is_a?(String) ? raw.split(",") : Array(raw)
217
+ list.map { |t| t.to_s.strip }.reject(&:empty?)
218
+ end
112
219
  end
113
220
  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", []) : []
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Insika
4
- # RFC-0015 §5.2 — WHERE a message that arrived mid-run is allowed to enter the
4
+ # WHERE a message that arrived mid-run is allowed to enter the
5
5
  # conversation.
6
6
  #
7
7
  # A customer who corrects themselves while the agent is calling tools ("1234567",
data/lib/insika/store.rb CHANGED
@@ -5,7 +5,7 @@ module Insika
5
5
  # Namespace-scoped KV, transactional when the backend supports it.
6
6
  # Every implementation passes the SAME contract suite
7
7
  # (lib/insika/testing/store_contract.rb — requirable from outside the repo,
8
- # RFC-0018 A4). Values must be JSON-serializable.
8
+ # Values must be JSON-serializable.
9
9
  #
10
10
  # scope: String — separates domains/tenants (e.g. "sessions", "tasks:tenant_x")
11
11
  # key: Hierarchical String (e.g. "task:123", "checkpoint:123:turn:4")