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
data/docs/REFINEMENT.md CHANGED
@@ -149,7 +149,7 @@ it did before, so no transcript needs migrating.
149
149
 
150
150
  `repetition` counts only what a customer said and `safe_reply` reads only what the
151
151
  engine said, both from this field. A message that declares nothing falls back to the
152
- old guess (an injected fragment opens with its own tag, `<cacau_cep_obrigatorio> …`,
152
+ old guess (an injected fragment opens with its own tag, `<store_cep_required> …`,
153
153
  which no customer types) — that heuristic now runs only on messages that made no
154
154
  claim about themselves.
155
155
 
@@ -174,7 +174,7 @@ sets the defaults:
174
174
 
175
175
  ```ruby
176
176
  Insika.agent "bia" do
177
- model "deepseek-chat"
177
+ model "deepseek-v4-flash"
178
178
  refine window: { last_sessions: 200 }, max_findings: 20
179
179
  end
180
180
  ```
@@ -186,8 +186,8 @@ end
186
186
  | `exclude_sessions` | none | session-id prefixes to drop |
187
187
  | `mode` | `"report"` | `report` reads and writes nothing. `propose` allows a gated, human-approved edit (below). `auto_apply` lets a gate-passing edit land unattended — off by default, and read [what it costs you](#applying-without-a-human) first. A mode the engine does not know is refused, never silently downgraded |
188
188
  | `files` | none | the ONLY files a proposal may edit. Empty means report-only |
189
- | `proposer` | the platform `utility_model` | which model writes the candidate (`"deepseek/deepseek-chat"` or a bare model name). Neither set means no proposal — the engine never picks a model to spend your budget on |
190
- | `proposers` | falls back to `proposer` | a **panel**: several models, each writing its own candidate. `["deepseek/deepseek-chat", {model: "gpt-5-mini", provider: "openai"}]` — either syntax |
189
+ | `proposer` | the platform `utility_model` | which model writes the candidate (`"deepseek/deepseek-v4-flash"` or a bare model name). Neither set means no proposal — the engine never picks a model to spend your budget on |
190
+ | `proposers` | falls back to `proposer` | a **panel**: several models, each writing its own candidate. `["deepseek/deepseek-v4-flash", {model: "gpt-5-mini", provider: "openai"}]` — either syntax |
191
191
  | `budget.tokens` | unlimited | what one run may spend across every proposal and every gate replay |
192
192
  | `max_edits` | 3 | edits a single proposal may carry |
193
193
  | `auto_apply_max_edits` | 1 | edits an **unattended** apply may carry. A bigger diff waits for a person |
@@ -306,7 +306,7 @@ first question worth asking is whether the finding it addresses is behaviour at
306
306
 
307
307
  ```ruby
308
308
  refine mode: "propose", files: %w[TOOLS.md],
309
- proposers: ["deepseek/deepseek-chat", "gpt-5-mini"],
309
+ proposers: ["deepseek/deepseek-v4-flash", "gpt-5-mini"],
310
310
  budget: { tokens: 200_000 }
311
311
  ```
312
312
 
@@ -440,7 +440,7 @@ not reasoned about.
440
440
  rest is detail. A regression is "a case that was passing now fails" — so an edit
441
441
  that breaks something no case covers passes cleanly. Two shallow cases wave almost
442
442
  anything through. If you want the loop to protect a behaviour, there has to be a
443
- case for that behaviour; that is the price [D4](#what-the-gate-needs) is charging,
443
+ case for that behaviour; that is the price [the gate](#what-the-gate-needs) is charging,
444
444
  and it is charged in curation work, not in configuration.
445
445
 
446
446
  Three things it will **not** catch, and two of them are the engine working correctly:
data/docs/RELEASING.md CHANGED
@@ -8,7 +8,7 @@ permalink: /releasing/
8
8
  # Releasing
9
9
 
10
10
  How an `insika` gem release is cut, and how the install is **proven** before the
11
- push. The rule that matters (RFC-0018 §8): a green suite is not a green gem —
11
+ push. The rule that matters: a green suite is not a green gem —
12
12
  the suite resolves everything by path, so the entire class of packaging failure
13
13
  is invisible to it. Do not publish on rspec alone.
14
14
 
@@ -18,7 +18,7 @@ is invisible to it. Do not publish on rspec alone.
18
18
  2. `lib/insika/version.rb` carries the version being published.
19
19
  3. Every new `lib/` file is **tracked in git**. The gemspec's `files` come from
20
20
  `git ls-files`: an untracked file builds without a warning and the installed
21
- gem fails at `require` — this is exactly the failure E1 exists to catch.
21
+ gem fails at `require` — this is exactly the failure this proof exists to catch.
22
22
 
23
23
  ## Cut the gem
24
24
 
@@ -26,7 +26,7 @@ is invisible to it. Do not publish on rspec alone.
26
26
  gem build insika.gemspec # -> insika-<version>.gem
27
27
  ```
28
28
 
29
- ## Prove the install (E1/E3) — from OUTSIDE the repo
29
+ ## Prove the install — from OUTSIDE the repo
30
30
 
31
31
  Install into a clean gem home and run the four shapes from a directory that is
32
32
  not the checkout, with the repo's `lib/` nowhere on the load path:
@@ -39,7 +39,7 @@ cd "$T"
39
39
  # 1. reply in-process
40
40
  GEM_HOME="$T/gemhome" GEM_PATH="$T/gemhome" ruby -e '
41
41
  require "insika"
42
- agent = Insika.agent("assistant") { model "deepseek-chat"; provider :deepseek }
42
+ agent = Insika.agent("assistant") { model "deepseek-v4-flash"; provider :deepseek }
43
43
  puts agent.reply("hi")' # needs DEEPSEEK_API_KEY
44
44
 
45
45
  # 2. serve — /studio login 200, /v1/responses streams, /start.md 200
@@ -50,7 +50,7 @@ GEM_HOME="$T/gemhome" GEM_PATH="$T/gemhome" ruby -e '
50
50
  Shapes 2–4 are the ones that fail when a file is missing from the gem (the
51
51
  Studio's `views/`, `assets/dist/`, the onboarding docs); run all four.
52
52
 
53
- Then the load guard, from the **installed** gem (E3) — no test double:
53
+ Then the load guard, from the **installed** gem — no test double:
54
54
 
55
55
  ```bash
56
56
  GEM_HOME="$T/gemhome" GEM_PATH="$T/gemhome" ruby -e '
@@ -66,5 +66,5 @@ gem push insika-<version>.gem
66
66
  ```
67
67
 
68
68
  Publishing is irreversible in practice — a yanked 0.1.0 is a bad first
69
- impression. The version number is cheap; the name is not. E1 before push, and
70
- nothing else.
69
+ impression. The version number is cheap; the name is not. The install proof before
70
+ push, and nothing else.
@@ -63,7 +63,7 @@ whole surface answers `503`, never open by omission.
63
63
  | `BIND` | `http://localhost:9292` | host:port |
64
64
  | `ADMIN_TOKEN` | `local-demo` | token for `/studio` |
65
65
  | `OPENCLAW_GATEWAY_TOKEN` | falls back to `ADMIN_TOKEN` | Bearer for the whole `/v1` + `/a2a` surface |
66
- | `DEEPSEEK_MODEL` | `deepseek-chat` | model |
66
+ | `DEEPSEEK_MODEL` | `deepseek-v4-flash` | model |
67
67
 
68
68
  With persistence:
69
69
 
data/docs/SECURITY.md CHANGED
@@ -47,16 +47,26 @@ dispatch **any** registered authoring Command (`write_agent_file`, `write_data_t
47
47
  `upsert_llm_provider`, `update_settings`, `delete_agent`). Treat that token as
48
48
  operator-grade — it is not a read key, and a leak is agent takeover, not just usage.
49
49
 
50
- > **The auth decision (RFC-0018, decided for 0.1.0): one deployment, one token;
51
- > multi-tenancy belongs to the host.** There is exactly one credential per
52
- > surface `OPENCLAW_GATEWAY_TOKEN` for `/v1`, `ADMIN_TOKEN` for the Studio —
53
- > and no per-caller scoping inside the engine. That is what the code has always
54
- > done; this paragraph is the decision written down so nothing built on top
55
- > inherits an assumption nobody stated. Serving two tenants from one mount means
56
- > putting the app behind your own authentication (see
57
- > [Embedding](EMBEDDING.md#embedding-is-not-multi-tenancy)), and scoped auth
58
- > one mount, several tokens with different powers is a real RFC the day an
59
- > adopter needs it, not a someday.
50
+ ### Multi-tenant mode (`INSIKA_TENANCY=multi_tenant`)
51
+
52
+ A single operator-grade token cannot host N stores. With `INSIKA_TENANCY=multi_tenant`
53
+ the Bearer is resolved to a **principal** before the routes:
54
+
55
+ - **Per-tenant tokens** (`POST /v1/commands/issue_tenant_token`) scope a caller to
56
+ one tenant; **operator** tokens (and the legacy gateway token an existing
57
+ deployment switching modes keeps its credential) have the run of the deployment.
58
+ - Tokens are stored **only as SHA-256 hashes**; the plaintext is shown exactly once,
59
+ at issue time. Rotation (`rotate_tenant_token`) and revocation (`revoke_token`)
60
+ are operator commands — revoking one tenant's token never touches another's.
61
+ - A tenant principal reaches only its **own runtime surfaces** (`/v1/sessions`,
62
+ `/v1/messages`, `/v1/responses`, workflow runs, and its own session/task/event
63
+ reads). Every authoring/provisioning/config surface answers `403` to a tenant.
64
+ - Isolation is the key, not a convention: a tenant's sessions live under
65
+ `<tenant>:<session-id>`, its commands carry `meta.tenant` (memory scoping,
66
+ event tagging), and reading another tenant's session/task reads as `404`.
67
+
68
+ `single_tenant` (the default) is exactly the classic behavior above — one
69
+ operator credential, no principal, no stamping.
60
70
 
61
71
  ## The `/v1` contract is versioned by date
62
72
 
@@ -172,7 +182,10 @@ moderator off. See [`examples/guardrails/`](https://github.com/guizaols/insika/t
172
182
  - **Input** — deterministic detectors (prompt-injection, abuse) run *before* the
173
183
  model. A flagged input gets a **safe refusal without burning a model turn** — an
174
184
  injection or a flood never reaches the provider. An LLM moderator can be layered
175
- on top.
185
+ on top. The moderator is **fail-open**: an error or an unparseable reply never
186
+ blocks a legitimate customer — but silence is not a negative. That third state
187
+ surfaces as a `:guardrail_flagged` event with category `moderator_unavailable`,
188
+ so a degraded tier is distinguishable from a healthy one in the audit stream.
176
189
  - **Output** — moderation plus PII/secret redaction on the streamed response, and
177
190
  a post-turn validator.
178
191
 
data/docs/SKILLS.md CHANGED
@@ -22,6 +22,8 @@ See [`examples/skills/`](https://github.com/guizaols/insika/tree/main/examples/s
22
22
  ---
23
23
  name: refunds # must equal the directory name
24
24
  description: When and how to process a refund # the Level-1 trigger text
25
+ triggers: [refund, money back] # optional: deterministic activation (below)
26
+ companions: [refund-policy] # optional: skills this one cannot work without
25
27
  ---
26
28
 
27
29
  <the full playbook body — loaded only on demand>
@@ -33,17 +35,121 @@ which the `name:` field must match.
33
35
  ## Progressive loading: two levels
34
36
 
35
37
  - **Level 1 — metadata only.** A context provider injects an `<available_skills>`
36
- list (the name + one-line description of every allowed skill) into the system
37
- prompt, telling the model to load a skill before acting on it. Cheap, and always
38
- present for allowed skills.
38
+ list into the system prompt — the name, the one-line description and the
39
+ `triggers:` of every allowed skill — telling the model to load a skill before
40
+ acting on it. Cheap, and always present for allowed skills. **This is the routing
41
+ table, and it is generated:** it cannot disagree with the allowlist, so do not
42
+ hand-write one in a prompt file (see [Drift guards](#drift-guards)).
39
43
  - **Level 2 — the full body.** A built-in `load_skill` tool returns the skill body
40
44
  on demand. It enforces the agent's skill allowlist and is wired **automatically**
41
45
  whenever the agent has any allowed skills — you do not add it to `tools_allow`.
46
+ - **Deterministic activation — `triggers:`.** When the user message contains one
47
+ of the skill's `triggers`, the body is injected for that turn — no model
48
+ decision, no `load_skill` call. Only matched skills, only that turn. Use it for
49
+ skills that MUST fire on known phrases; model loading stays as the fallback for
50
+ everything else. Matching is on **whole words**, case-insensitive and
51
+ accent-folded: `presente` fires on *um presente* and *presénte*, never inside
52
+ *apresente*.
42
53
 
43
54
  The Level-1 list is budgeted like any other context fragment
44
55
  (see [Context](CONTEXT.md)); the Level-2 body only costs tokens on the turns that
45
56
  open it.
46
57
 
58
+ ### Only trigger a skill that can finish the turn alone
59
+
60
+ A `triggers:` match is not a hint — the body lands in the prompt with the
61
+ authority of an instruction. So put triggers only on a skill that is
62
+ **self-sufficient** for the turn it fires on.
63
+
64
+ The failure mode is counter-intuitive: injecting a skill that is only *part* of
65
+ the answer is **worse than injecting nothing**. Give the model a reference table
66
+ whose procedure lives in a companion skill, and it now holds a plausible
67
+ half-recipe — so it never calls `load_skill` for the other half, and improvises
68
+ the missing part. A precise trigger on the wrong kind of skill still breaks the
69
+ turn.
70
+
71
+ Reference tables, vocabularies and lookup maps are the skills to leave on
72
+ level 1. Whole procedures ("run this journey", "recover from this error") are the
73
+ ones worth triggering.
74
+
75
+ ## Always-on skills: `skills_eager`
76
+
77
+ A skill that every turn needs — output format, the marker vocabulary, how to
78
+ recover from a failed tool — should not depend on the model choosing to load it.
79
+ Name it on the **agent** and its body is in the prompt on every turn:
80
+
81
+ ```ruby
82
+ Insika.agent("consultant") do
83
+ skills_eager "recommendation-formatting", "tool-error-recovery"
84
+ # skills_eager # or: every allowed skill (a corpus that fits the budget)
85
+ # skills_eager false # or: none — the default
86
+ end
87
+ ```
88
+
89
+ An eager skill also **leaves level 1**: it is absent from `<available_skills>` and
90
+ `load_skill` refuses to serve it. There is no level 2 left to fetch, and a catalog
91
+ pointing at a body already in the prompt only invites a call that pays for a
92
+ duplicate.
93
+
94
+ ### Why the agent decides, and not the skill
95
+
96
+ Eagerness used to be an `eager: true` key in the `SKILL.md` frontmatter. That put
97
+ the decision on the wrong object: **skills are shared.** `escalation-to-human`,
98
+ `recommendation-formatting` and `tool-error-recovery` each sit in several agents'
99
+ allowlists, and one flag on the skill forced one decision onto every agent holding
100
+ it — with no way to be always-on for the agent that needs it and discretionary for
101
+ the one that does not.
102
+
103
+ `skills_eager` is a per-agent list, so the same shared skill can be both. The
104
+ frontmatter key is **ignored** — `insika doctor` flags any skill still carrying it,
105
+ and names the agent setting that replaced it.
106
+
107
+ A name that is not in the agent's `skills` allowlist is a no-op (eagerness is
108
+ intersected with what the agent is allowed to see); `doctor` flags that too.
109
+
110
+ ### Keep the discretionary skills on the load path
111
+
112
+ Making everything eager is a trap, and the reason is not the tokens: **it costs you
113
+ the signal**. When every body is present on every turn, "which skills were active"
114
+ is always "all of them", and you can no longer tell which one the model reached for.
115
+ The `load_skill` call is the only record of that choice — it is a persisted tool
116
+ message, so it shows up in the transcript on its own.
117
+
118
+ So the split is: **eager for what the turn always needs, `load_skill` for what the
119
+ turn might need.** The second group is where you want the model's choice on the
120
+ record, because that is the group where a wrong choice is worth seeing.
121
+
122
+ The token trade is real but smaller than it looks: eager bodies sit at a fixed
123
+ position ahead of the history, so they belong to the **cacheable prefix**, and they
124
+ are still evictable under budget pressure, unlike the pinned identity. Conditional
125
+ injection is what breaks that prefix, on exactly the turns it fires.
126
+
127
+ ## Seeing which skills were active, and why
128
+
129
+ The load path is legible for free: `load_skill` is a tool, so the call is a
130
+ persisted message and shows up in the transcript on its own. The deterministic paths
131
+ are not a call, so the engine reports them itself — **with a reason per skill**:
132
+
133
+ | reason | what it means |
134
+ |---|---|
135
+ | `eager` | the agent's `skills_eager` names it, so every turn gets it |
136
+ | `trigger:<phrase>` | this message matched that `triggers:` entry — the phrase as **authored**, so you can find the line to edit |
137
+ | `pack` | a plugin's own context provider supplied the body |
138
+
139
+ Where it shows up, per turn, in the Studio session screen:
140
+
141
+ - an **activation card in the transcript thread**, placed at the top of its turn and
142
+ in the same visual language as a tool result, so a context-injected skill and a
143
+ model-loaded one read the same way;
144
+ - the **Context card**, next to that category's token count, for the after-the-fact
145
+ audit;
146
+ - the `skill_activated` **event** (`skills: [{name, reason}]`, `source: "context"`),
147
+ with full task/session correlation.
148
+
149
+ All three are computed from what actually reached the prompt **after the budget
150
+ cut**: a body the budget evicted is reported as an eviction, never as an activation.
151
+ A turn that mixes both paths is labelled `mixed`, and each line keeps its own reason.
152
+
47
153
  ## Where skills live: the store, over a disk seed
48
154
 
49
155
  - Skills live as **rows in SQLite** — one row per skill, holding the entire
@@ -58,6 +164,68 @@ open it.
58
164
  > runtime operation (Studio / API / DSL), not a commit. See
59
165
  > [Context](CONTEXT.md#the-volume).
60
166
 
167
+ ## Pairs that must not break: `companions:`
168
+
169
+ Injecting *part* of an answer is worse than injecting nothing. Give the model a line
170
+ map whose query-construction rules live in another skill and it holds a plausible
171
+ half-recipe — so it never calls `load_skill` for the other half, and improvises the
172
+ missing part. Measured on a real pack: the map arrived by trigger, the rules did not,
173
+ and the searches came out malformed. Twice.
174
+
175
+ Declare the dependency and it travels with whatever brought it — a trigger match, the
176
+ agent's eager set, or a `load_skill` call (which returns both bodies in the one call):
177
+
178
+ ```yaml
179
+ companions: [query-construction]
180
+ ```
181
+
182
+ Two deliberate limits:
183
+
184
+ - **One level, no transitive walk.** A cycle would be a hang and a chain a budget
185
+ blowout, and "cannot work without" is a direct relationship.
186
+ - **Never widens an allowlist.** A companion the agent is not allowed to load is
187
+ simply absent; `insika doctor` flags the declaration instead.
188
+
189
+ ## Specializing a shared skill for one agent
190
+
191
+ Skills are shared on purpose: `escalation-to-human` belongs in several agents'
192
+ allowlists. But sometimes one agent needs a different version of the same skill —
193
+ its own return policy, its own store name — and forking it under a second name
194
+ throws the sharing away and leaves two things to keep in step.
195
+
196
+ So the store has a second scope, and resolution is a **precedence chain** with one
197
+ more dimension:
198
+
199
+ ```
200
+ for agent A, skill <name>: (A, <name>) in the agent scope,
201
+ then <name> in the shared scope
202
+ ```
203
+
204
+ Three cases fall out of that one rule:
205
+
206
+ | case | what exists in the store |
207
+ |---|---|
208
+ | **shared** | only the shared record — every agent gets the same body |
209
+ | **override** | both — the agent's wins, for that agent only |
210
+ | **agent-private** | only the agent record — invisible elsewhere, and the name may collide freely |
211
+
212
+ The **name never changes.** An override keeps saying `name: escalation-to-human`
213
+ inside, because it *is* that skill, specialized; the allowlist, the `<available_skills>`
214
+ list, `load_skill` and the activation card all keep showing the bare name. What
215
+ decides which record you get is its **position in the store**, never the frontmatter —
216
+ otherwise an override would clobber the shared skill for everybody.
217
+
218
+ Write one with `agent:`, and remove it the same way (which un-specializes, leaving
219
+ the shared skill in place):
220
+
221
+ ```ruby
222
+ dispatch(:write_skill, { name: "escalation-to-human", agent: "store-cacau", content: md })
223
+ dispatch(:delete_skill, { name: "escalation-to-human", agent: "store-cacau" })
224
+ ```
225
+
226
+ In the Studio: **Skills → specialize for this agent**, which seeds the override from
227
+ the shared body.
228
+
61
229
  ## Making a new skill "show up"
62
230
 
63
231
  For an agent to actually use a skill, **both** conditions must hold:
@@ -89,6 +257,24 @@ Two ways to satisfy both:
89
257
  `load_skill` errors → the store row is missing or misnamed (condition 1); the
90
258
  `name:` frontmatter must equal the directory name.
91
259
 
260
+ ## Drift guards
261
+
262
+ A skill catalog drifts against the prose that routes to it, and every way it happened
263
+ on the pilot was silent — found by reading a customer conversation days later. So the
264
+ routing table is **generated** (above), and `insika doctor` reports the residue the
265
+ generator cannot remove. Every check takes mechanical inputs only — names, allowlists,
266
+ agent identities — because one false positive is enough for an operator to stop
267
+ reading the doctor:
268
+
269
+ | finding | what it means |
270
+ |---|---|
271
+ | a prompt file names a skill outside that agent's allowlist | leftover hand-written routing: the model is told to use something it cannot load |
272
+ | a shared skill's body names one of its own holders | specialized text in shared clothing — the other holders are served that store's policy as their own. Specialize it instead |
273
+ | a body references another catalog skill without declaring it a companion | the pair can still arrive apart |
274
+ | a declared companion is outside an agent's allowlist | the pair cannot travel for that agent, and the engine will not widen the allowlist |
275
+ | a skill still declares `eager:` in its frontmatter | the key is ignored; the decision moved to the agent |
276
+ | an agent marks a skill eager that it does not allow | the name is a no-op |
277
+
92
278
  ## See also
93
279
 
94
280
  - [Context](CONTEXT.md) — how the skills list is budgeted into a turn.
data/docs/WHY.md CHANGED
@@ -32,7 +32,7 @@ the same speed-to-first-agent story Python teams tell, without leaving Ruby:
32
32
  require "insika"
33
33
 
34
34
  agent = Insika.agent("assistant") do
35
- model "deepseek-chat"
35
+ model "deepseek-v4-flash"
36
36
  provider :deepseek
37
37
  instructions "You are a concise, friendly assistant."
38
38
  end
data/docs/WORKFLOWS.md CHANGED
@@ -40,8 +40,8 @@ agents by id and those agents must resolve in the same runtime:
40
40
  newsroom = Insika.system do
41
41
  provider :deepseek
42
42
 
43
- agent("writer") { model "deepseek-chat"; instructions "Write ONE paragraph." }
44
- agent("editor") { model "deepseek-chat"; instructions "Rewrite as ONE sentence." }
43
+ agent("writer") { model "deepseek-v4-flash"; instructions "Write ONE paragraph." }
44
+ agent("editor") { model "deepseek-v4-flash"; instructions "Rewrite as ONE sentence." }
45
45
 
46
46
  workflow "publish",
47
47
  description: "Draft a paragraph, then tighten it.",
data/docs/index.md CHANGED
@@ -34,7 +34,7 @@ Ruby `>= 3.3` and a provider key (the demo uses DeepSeek). The whole program:
34
34
  require "insika"
35
35
 
36
36
  assistant = Insika.agent("assistant") do
37
- model "deepseek-chat"
37
+ model "deepseek-v4-flash"
38
38
  provider :deepseek
39
39
  instructions "You are Bia, a concise and friendly assistant. Answer briefly."
40
40
  end
@@ -51,7 +51,7 @@ require "insika"
51
51
 
52
52
  assistant = Insika.agent("assistant") do
53
53
  provider :deepseek # ← the provider slug from {{MODELS_URL}}
54
- model "deepseek-chat" # ← a model id from {{MODELS_URL}}
54
+ model "deepseek-v4-flash" # ← a model id from {{MODELS_URL}}
55
55
  instructions "You are a concise, friendly assistant. Answer briefly."
56
56
  end
57
57