insika 0.3.0 → 0.7.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 (190) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +180 -0
  3. data/README.md +45 -10
  4. data/bin/insika +684 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +94 -403
  7. data/docs/API.md +5 -5
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +14 -14
  12. data/docs/CONTEXT.md +9 -7
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +71 -3
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +128 -3
  17. data/docs/FACTS.md +3 -3
  18. data/docs/HARVEST.md +5 -6
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +2 -2
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +15 -10
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +14 -9
  27. data/docs/RELEASING.md +4 -4
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +3 -3
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +22 -6
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +2 -2
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +152 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +4 -4
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +8 -5
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +73 -16
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/channel_delivery.rb +1 -1
  62. data/lib/insika/chat_builder.rb +22 -2
  63. data/lib/insika/commands/agent_payload.rb +2 -2
  64. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  65. data/lib/insika/commands/delete_artifact.rb +35 -0
  66. data/lib/insika/commands/delete_concept.rb +34 -0
  67. data/lib/insika/commands/delete_mcp.rb +6 -2
  68. data/lib/insika/commands/delete_tenant_data.rb +15 -3
  69. data/lib/insika/commands/gate_refinement.rb +1 -1
  70. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  71. data/lib/insika/commands/restore_concept.rb +34 -0
  72. data/lib/insika/commands/seed_demo_data.rb +31 -0
  73. data/lib/insika/commands/upsert_mcp.rb +6 -3
  74. data/lib/insika/commands/write_concept.rb +57 -0
  75. data/lib/insika/context/priority.rb +2 -0
  76. data/lib/insika/context/providers/knowledge.rb +108 -0
  77. data/lib/insika/context/providers/prompt.rb +30 -24
  78. data/lib/insika/cron.rb +189 -0
  79. data/lib/insika/demo/agent_attrs.rb +43 -0
  80. data/lib/insika/demo/golden_cases.rb +81 -0
  81. data/lib/insika/demo/seeder.rb +336 -0
  82. data/lib/insika/doctor.rb +176 -8
  83. data/lib/insika/dsl/definition.rb +3 -2
  84. data/lib/insika/dsl/runtime.rb +60 -79
  85. data/lib/insika/dsl/server_boot.rb +23 -1
  86. data/lib/insika/dsl/system.rb +10 -2
  87. data/lib/insika/dsl.rb +103 -2
  88. data/lib/insika/env_schema.rb +16 -1
  89. data/lib/insika/evals/golden.rb +41 -4
  90. data/lib/insika/evals/judge.rb +47 -2
  91. data/lib/insika/evals/pairwise.rb +11 -0
  92. data/lib/insika/evals/persona.rb +98 -0
  93. data/lib/insika/evals/runner.rb +9 -0
  94. data/lib/insika/evals/simulator.rb +225 -0
  95. data/lib/insika/evals/transport.rb +83 -1
  96. data/lib/insika/event_stream.rb +10 -0
  97. data/lib/insika/executor.rb +231 -55
  98. data/lib/insika/followup_policy.rb +2 -25
  99. data/lib/insika/golden_store.rb +16 -1
  100. data/lib/insika/grounding/matcher.rb +1 -1
  101. data/lib/insika/knowledge.rb +680 -0
  102. data/lib/insika/knowledge_store.rb +140 -0
  103. data/lib/insika/mcp_client.rb +94 -0
  104. data/lib/insika/mcp_json.rb +74 -0
  105. data/lib/insika/mcp_live_tool.rb +43 -0
  106. data/lib/insika/mcp_store.rb +98 -26
  107. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  108. data/lib/insika/mcp_tool_registry.rb +100 -0
  109. data/lib/insika/media.rb +115 -31
  110. data/lib/insika/message_origin.rb +1 -1
  111. data/lib/insika/middleware.rb +9 -0
  112. data/lib/insika/onboarding.rb +17 -1
  113. data/lib/insika/outcome_store.rb +1 -1
  114. data/lib/insika/overlay_tool_registry.rb +37 -17
  115. data/lib/insika/packaging.rb +2 -2
  116. data/lib/insika/profile_source.rb +8 -1
  117. data/lib/insika/prompt_catalog.rb +10 -0
  118. data/lib/insika/retention.rb +36 -1
  119. data/lib/insika/router/app.rb +157 -0
  120. data/lib/insika/router/backend_pool.rb +98 -0
  121. data/lib/insika/router/hash_ring.rb +55 -0
  122. data/lib/insika/router/proxy_body.rb +34 -0
  123. data/lib/insika/router/session_key.rb +54 -0
  124. data/lib/insika/router.rb +18 -0
  125. data/lib/insika/schedule.rb +177 -0
  126. data/lib/insika/schedule_engine.rb +314 -0
  127. data/lib/insika/schedule_store.rb +208 -0
  128. data/lib/insika/server/app.rb +105 -15
  129. data/lib/insika/server/rack_app.rb +5 -1
  130. data/lib/insika/server/responses.rb +1 -1
  131. data/lib/insika/skill_catalog.rb +12 -0
  132. data/lib/insika/steer_injector.rb +21 -10
  133. data/lib/insika/studio/app.rb +567 -45
  134. data/lib/insika/studio/assets/dist/application.css +1 -1
  135. data/lib/insika/studio/assets/dist/application.js +21 -21
  136. data/lib/insika/studio/forms.rb +46 -5
  137. data/lib/insika/studio/nav_icons.rb +14 -1
  138. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  139. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  140. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  141. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  142. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  143. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  144. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  145. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  146. data/lib/insika/studio/views/_agents_master.erb +44 -0
  147. data/lib/insika/studio/views/_message.erb +49 -32
  148. data/lib/insika/studio/views/agent_detail.erb +61 -820
  149. data/lib/insika/studio/views/agents.erb +70 -57
  150. data/lib/insika/studio/views/artifact.erb +23 -0
  151. data/lib/insika/studio/views/artifacts.erb +59 -0
  152. data/lib/insika/studio/views/evals.erb +2 -2
  153. data/lib/insika/studio/views/facts.erb +1 -1
  154. data/lib/insika/studio/views/funnel.erb +1 -1
  155. data/lib/insika/studio/views/home.erb +106 -67
  156. data/lib/insika/studio/views/knowledge.erb +123 -0
  157. data/lib/insika/studio/views/layout.erb +14 -11
  158. data/lib/insika/studio/views/mcp.erb +174 -80
  159. data/lib/insika/studio/views/session.erb +231 -177
  160. data/lib/insika/studio/views/settings.erb +39 -1
  161. data/lib/insika/studio/views/skills.erb +1 -1
  162. data/lib/insika/studio/views/tools.erb +24 -9
  163. data/lib/insika/templates/browser-agent/README.md +36 -0
  164. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  165. data/lib/insika/templates/daily-digest/README.md +38 -0
  166. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  167. data/lib/insika/templates/repo-explorer/README.md +36 -0
  168. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  169. data/lib/insika/templates/research-analyst/README.md +26 -0
  170. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  171. data/lib/insika/templates/review-panel/README.md +20 -0
  172. data/lib/insika/templates/review-panel/agent.rb +50 -0
  173. data/lib/insika/templates/travel-planner/README.md +35 -0
  174. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  175. data/lib/insika/templates.rb +112 -0
  176. data/lib/insika/tick.rb +24 -12
  177. data/lib/insika/timezone.rb +45 -0
  178. data/lib/insika/tools/generate_image.rb +52 -7
  179. data/lib/insika/tools/load_knowledge.rb +74 -0
  180. data/lib/insika/tools/run_persona_eval.rb +328 -0
  181. data/lib/insika/tools/save_artifact.rb +95 -0
  182. data/lib/insika/turn_output.rb +1 -1
  183. data/lib/insika/turn_state.rb +15 -4
  184. data/lib/insika/version.rb +1 -1
  185. data/lib/insika/wiring/graph.rb +184 -12
  186. data/lib/insika/wiring/graph_chat.rb +102 -0
  187. data/lib/insika.rb +57 -0
  188. metadata +105 -5
  189. data/docs/build.md +0 -14
  190. data/docs/understand.md +0 -10
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Insika
6
+ module Tools
7
+ # `save_artifact` — the agent delivers a page (a report, a digest) to a
8
+ # destination that survives the channel: title + content in, a URL out,
9
+ # which it can include in a channel message ("today's report: <url>").
10
+ #
11
+ # A REGISTRY tool (not a ChatBuilder system tool): the per-agent
12
+ # `tools_allow` is the switch — registered `optional: true`, so an agent
13
+ # that did not name it cannot call it, and nothing global enables it.
14
+ # The bindings (tenant, agent, task) are the tool INSTANCE's, deposited
15
+ # by the Executor into `turn_context` like a data-tool's — never
16
+ # parameters the model types.
17
+ class SaveArtifact < RubyLLM::Tool
18
+ description "Save an HTML (or markdown / SVG) page — a report, a digest, " \
19
+ "a summary — and get back a URL to share. Use when the answer " \
20
+ "is a document the channel message cannot carry."
21
+ param :title, desc: "Short human-readable title (<= 200 chars)"
22
+ param :content, desc: "The page content: HTML with inline SVG for charts, " \
23
+ "markdown, or SVG. No scripts, no external resources."
24
+ param :mime, desc: "text/html (default), text/markdown, or image/svg+xml",
25
+ required: false
26
+
27
+ def name = "save_artifact"
28
+
29
+ def initialize(artifact_store:, base_url: nil, signing_key: nil, signing_ttl: nil,
30
+ max_bytes: nil, event_stream: nil)
31
+ @artifact_store = artifact_store
32
+ @base_url = base_url.to_s.sub(%r{/\z}, "")
33
+ @signing_key = signing_key
34
+ @signing_ttl = signing_ttl
35
+ @max_bytes = max_bytes
36
+ @event_stream = event_stream
37
+ super()
38
+ end
39
+
40
+ # Per-turn bindings, deposited by the Executor (ToolAssembly's
41
+ # `turn_context=` seam — the same one data-tools use). From the TURN,
42
+ # never from the model (the tenant-lesson: an id the model types is
43
+ # inventable, and the turn looks right anyway).
44
+ attr_reader :turn_context
45
+
46
+ def turn_context=(ctx)
47
+ @turn_context = (ctx || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
48
+ end
49
+
50
+ def execute(title:, content:, mime: "text/html")
51
+ # the deployment's size cap rides the tool instance (INSIKA_ARTIFACT_
52
+ # MAX_BYTES at wiring); absent -> the store's default.
53
+ attrs = { tenant: binding_tenant, agent: @turn_context&.dig(:agent_id),
54
+ task_id: @turn_context&.dig(:task_id), title: title, mime: mime,
55
+ content: content }
56
+ attrs[:max_bytes] = @max_bytes if @max_bytes
57
+ record = @artifact_store.create(**attrs)
58
+ # the authenticated Studio URL is the always-on answer; the signed
59
+ # link exists ONLY when a key was configured (it shares OUTSIDE the
60
+ # Studio and expires).
61
+ url = Insika::ArtifactSigning.url_for(id: record.id, base: @base_url)
62
+ result = { id: record.id, url: url }
63
+ signed = Insika::ArtifactSigning.url_for(
64
+ id: record.id, base: @base_url, key: @signing_key, ttl: @signing_ttl
65
+ )
66
+ result[:signed_url] = signed if signed != url
67
+ emit(record)
68
+ result
69
+ rescue Insika::ValidationError => e
70
+ { error: e.message }
71
+ end
72
+
73
+ private
74
+
75
+ # The tenant is the AGENT's tenant: the declared command tenant, or the
76
+ # deployment's "platform" in single-tenant — never the chat id (a report
77
+ # belongs to the agent, not to whichever chat ran it), never a parameter
78
+ # the model types.
79
+ def binding_tenant
80
+ declared = @turn_context&.dig(:command_tenant).to_s
81
+ declared.empty? ? "platform" : declared
82
+ end
83
+
84
+ # :artifact_saved carries ids + title — NEVER the content (a report is
85
+ # customer data that rides the store, not the events).
86
+ def emit(record)
87
+ @event_stream&.emit(Insika::Event.new(
88
+ type: :artifact_saved,
89
+ data: { id: record.id, title: record.title },
90
+ meta: { task_id: @turn_context&.dig(:task_id) }
91
+ ))
92
+ end
93
+ end
94
+ end
95
+ end
@@ -33,7 +33,7 @@ module Insika
33
33
  # answer; the fragment is still on the stream as `:intermediate` for whoever is
34
34
  # debugging it.
35
35
  #
36
- # `halt_when` (PR #130) is the one case where narration IS the turn: the tool
36
+ # `halt_when` is the one case where narration IS the turn: the tool
37
37
  # already answered the customer, and the model's lead-in ("vou te inscrever
38
38
  # agora") is all the turn is worth. That text is retained and published by the
39
39
  # Executor's halt branch — see #halt_text.
@@ -31,10 +31,15 @@ module Insika
31
31
  :message_source, # WS9: how the turn's message entered (:voice when it came
32
32
  # from a transcribed audio part; nil = typed). Rides the
33
33
  # terminal event additively.
34
- :media_attachments, # WS9: the ask's attachments (image parts); nil = none.
35
- # The provider bills them; usage flows like any ask.
36
- # The first image URL also lands on turn_context[:image_url]
37
- # for data/HTTP tools (`{{ctx.image_url}}`).
34
+ :media_attachments, # WS9: the ask's attachments (image + document parts);
35
+ # nil = none. The provider bills them; usage flows like
36
+ # any ask. The first URL of each kind also lands on
37
+ # turn_context (`{{ctx.image_url}}` / `{{ctx.document_url}}`).
38
+ :image_attachments, # WS9: the IMAGE subset of media_attachments (nil = no
39
+ # inbound image this turn). The default edit SOURCE for
40
+ # generate_image — kept separate from
41
+ # media_attachments because that one may also hold a
42
+ # document, which is never a paint(with:) source.
38
43
  :output_parts, # WS9 (saída): media the turn GENERATED, as additive
39
44
  # parts ({ type:, mime_type:, base64:, model: }). Rides
40
45
  # the terminal event (output_parts) and the /v1/responses
@@ -114,6 +119,12 @@ module Insika
114
119
  # rules the run started under.
115
120
  attr_accessor :queue_policy
116
121
 
122
+ # Internal: the run's SteerInjector, or nil when steering is off (or the chat
123
+ # cannot host the boundary). Rebuilt per ATTEMPT — a WS3 fallback builds a new
124
+ # chat, and the injector belongs to the chat it appends to. The Executor reaches
125
+ # for it at the end of stage 6 to absorb whatever no tool boundary took.
126
+ attr_accessor :steer_injector
127
+
117
128
  # Internal: true when this turn re-enters the pipeline via
118
129
  # resume_task/recovery. The EdgeLimiter reads it to NEVER re-count or block a
119
130
  # turn that was already admitted — a crash/pause under a saturated window must
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Insika
4
- VERSION = "0.3.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "graph_chat"
4
+
3
5
  module Insika
4
6
  module Wiring
5
7
  # SHARED composition core for both roots: the minimal wiring
@@ -78,11 +80,21 @@ module Insika
78
80
  # followup) so the engine, the tools and the Studio always have stores.
79
81
  contact_store = Insika::ContactStore.new(store: backend)
80
82
  followup_store = Insika::FollowupStore.new(store: backend)
83
+ # the recurring-schedule rows: one row per
84
+ # declared schedule, declaration + runtime state. Built unconditionally
85
+ # (empty and free when no pack declares schedules) so the engine, the
86
+ # doctor and the Studio always have a store.
87
+ schedule_store = Insika::ScheduleStore.new(store: backend)
81
88
  # the distilled proposals + the latched dedup ledger +
82
89
  # the per-session markers. Built unconditionally (empty and free when
83
90
  # no pack declares distill) so the wiki, the engine and the LGPD
84
91
  # purges always have a store.
85
92
  proposal_store = Insika::ProposalStore.new(store: backend)
93
+ # concepts extracted from finished turns. Built
94
+ # unconditionally (empty and free when no pack declares knowledge)
95
+ # so the extractor, the Studio and the LGPD purges always have a
96
+ # store.
97
+ knowledge_store = Insika::KnowledgeStore.new(store: backend)
86
98
  # refinement RUNS (reports over real traffic). Runtime data,
87
99
  # same backend as sessions/tasks — the collector and the command that write it
88
100
  # are the root's business (deployment-only, like the memory commands).
@@ -93,6 +105,11 @@ module Insika
93
105
  # when no pack declares harvest) so the engine, the Studio and the
94
106
  # LGPD purges always have a store.
95
107
  harvest_store = Insika::HarvestStore.new(store: backend)
108
+ # the report destination — one record per run, no
109
+ # versioning. Built unconditionally (empty and free when nothing saves)
110
+ # so the tool, the routes, the retention sweep and the tenant purge
111
+ # always have a store.
112
+ artifact_store = Insika::ArtifactStore.new(store: backend)
96
113
 
97
114
  code_tool_registry = Insika::ToolRegistry.new
98
115
  workflow_registry = Insika::WorkflowRegistry.new
@@ -118,7 +135,10 @@ module Insika
118
135
  funnel_store: funnel_store,
119
136
  contact_store: contact_store,
120
137
  followup_store: followup_store,
138
+ schedule_store: schedule_store,
121
139
  proposal_store: proposal_store,
140
+ knowledge_store: knowledge_store,
141
+ artifact_store: artifact_store,
122
142
  code_tool_registry: code_tool_registry,
123
143
  workflow_registry: workflow_registry, policy_registry: policy_registry,
124
144
  capability_registry: Insika::CapabilityRegistry.new, hooks: Insika::Hooks.new,
@@ -141,6 +161,13 @@ module Insika
141
161
  def build(spine:, profiles:, tool_registry:, tool_catalog:, skill_catalog:,
142
162
  prompt_catalog:, guardrails:, context_providers:, edge_limiter: nil,
143
163
  executor_extra: {})
164
+ # the save_artifact tool: a REGISTRY tool registered OPTIONAL (the
165
+ # per-agent allowlist is the switch — an agent that did not name it
166
+ # cannot call it). Registered in the shared code registry, so both
167
+ # roots (base registry + the deployment's overlay, which composes it)
168
+ # expose it. The gem's require lives IN the factory block (loaded on
169
+ # the 1st instance, turn time -> wiring-load stays gem-free).
170
+ register_artifact_tool(spine)
144
171
  spine.hooks.register(:task, after: guardrails.output_validator)
145
172
  middleware = Insika::MiddlewareStack.new([edge_limiter, guardrails.input_guardrail].compact)
146
173
 
@@ -188,6 +215,9 @@ module Insika
188
215
  # parity). The base graph wires it over the same backend the context
189
216
  # trace uses.
190
217
  model_visible_trace_store: Insika::ModelVisibleTraceStore.new(store: spine.backend),
218
+ # the per-turn extraction hook (nil = the loop is off,
219
+ # parity). Gated per-agent by `profile.knowledge`.
220
+ knowledge_store: spine.knowledge_store,
191
221
  **executor_extra
192
222
  )
193
223
 
@@ -233,7 +263,8 @@ module Insika
233
263
  followup_store: spine.followup_store, # records age out too
234
264
  contact_store: spine.contact_store, # cells age out too
235
265
  proposal_store: spine.proposal_store, # proposals age out too
236
- harvest_store: spine.harvest_store # candidates/log/snapshots too
266
+ harvest_store: spine.harvest_store, # candidates/log/snapshots too
267
+ artifact_store: spine.artifact_store # reports expire on their own TTL
237
268
  ),
238
269
  interval: tick_env("INSIKA_TICK_INTERVAL") || Insika::Tick::DEFAULT_INTERVAL,
239
270
  stale_after: tick_env("INSIKA_TICK_STALE_AFTER") || Insika::Tick::DEFAULT_STALE_AFTER
@@ -256,6 +287,16 @@ module Insika
256
287
  contact_store: spine.contact_store, task_store: spine.task_store,
257
288
  profiles: profiles, executor: executor, event_stream: spine.event_stream
258
289
  )
290
+ # the recurring-schedule firer — the tick's
291
+ # fourth duty, wired here after the Tick, gated by its own claim
292
+ # window. Always built: a pack that declares `schedules` later finds
293
+ # its firer already on the tick (inert when no profile declares one).
294
+ executor.tick.schedule = Insika::ScheduleEngine.new(
295
+ store: spine.backend, schedule_store: spine.schedule_store,
296
+ task_store: spine.task_store, session_store: spine.session_store,
297
+ profiles: profiles, executor: executor, budget_ledger: spine.budget_ledger,
298
+ event_stream: spine.event_stream
299
+ )
259
300
  # the distillation engine — the tick-duty that finds idle
260
301
  # customer sessions and distills them on its own worker fiber (a
261
302
  # supervisor child, started in serving mode next to the tick).
@@ -307,13 +348,16 @@ module Insika
307
348
  memory_store: spine.memory_store, memory_audit_store: spine.memory_audit_store,
308
349
  refinement_store: spine.refinement_store,
309
350
  harvest_store: spine.harvest_store,
351
+ artifact_store: spine.artifact_store,
310
352
  outbox_store: spine.outbox_store, shadow_pair_store: spine.shadow_pair_store,
311
353
  inbound_log: spine.inbound_log,
312
354
  outcome_store: spine.outcome_store,
313
355
  funnel_store: spine.funnel_store,
314
356
  contact_store: spine.contact_store,
315
357
  followup_store: spine.followup_store,
358
+ schedule_store: spine.schedule_store,
316
359
  proposal_store: spine.proposal_store,
360
+ knowledge_store: spine.knowledge_store,
317
361
  token_store: spine.token_store, budget_ledger: spine.budget_ledger,
318
362
  circuit_state: spine.circuit_state,
319
363
  channel_registry: spine.channel_registry, channel_delivery: channel_delivery,
@@ -327,6 +371,49 @@ module Insika
327
371
  )
328
372
  end
329
373
 
374
+ # The boot step behind every root's `load_plugins` (Server::Boot's first
375
+ # named step). Discovers plugin manifests, validates them, requires the
376
+ # entries and registers their contributions into the ALREADY-BUILT graph —
377
+ # which is safe by construction: every seam the Loader touches resolves at
378
+ # turn time (registries and hooks by name, the middleware stack and the
379
+ # provider list per call, the catalogs on reload), and the step runs
380
+ # single-fiber before the server accepts connections.
381
+ #
382
+ # Roots, highest precedence first (duplicate id -> first root wins):
383
+ # 1. workspace — INSIKA_PLUGIN_DIR (the operator's override spot);
384
+ # 2. gems — whatever called Insika::Plugin.announce (default-enabled);
385
+ # 3. bundled — `bundled_root` (the repo's plugins/; requires enabling).
386
+ # INSIKA_PLUGINS enables workspace/bundled ids; INSIKA_PLUGINS_DISABLED is
387
+ # the absolute veto (deny wins, like every allowlist in the engine).
388
+ def load_plugins(graph, env: ENV, bundled_root: nil)
389
+ announced = Insika::Plugin.announced_roots
390
+ workspace = Insika::EnvSchema.read("INSIKA_PLUGIN_DIR", env)
391
+ roots = ([workspace] + announced + [bundled_root]).compact.map { |d| File.expand_path(d.to_s) }
392
+
393
+ result = Insika::Plugin::Loader.new(
394
+ roots: roots,
395
+ registries: {
396
+ tools: graph.code_tool_registry, workflows: graph.workflow_registry,
397
+ policies: graph.policy_registry, capabilities: graph.capability_registry,
398
+ channels: graph.channel_registry, hooks: graph.hooks,
399
+ middleware: graph.middleware, context_providers: graph.context_providers
400
+ },
401
+ enabled: plugin_csv(Insika::EnvSchema.read("INSIKA_PLUGINS", env)),
402
+ disabled: plugin_csv(Insika::EnvSchema.read("INSIKA_PLUGINS_DISABLED", env)),
403
+ announced_roots: announced, event_stream: graph.event_stream
404
+ ).load_all
405
+
406
+ # Plugin knowledge joins at the LOWEST precedence: an operator's
407
+ # workspace/authored skill always beats a same-named plugin one.
408
+ graph.skill_catalog.add_roots(result[:skill_dirs])
409
+ graph.prompt_catalog.add_roots(result[:prompt_dirs])
410
+ result
411
+ end
412
+
413
+ def plugin_csv(value)
414
+ value.to_s.split(",").map(&:strip).reject(&:empty?)
415
+ end
416
+
330
417
  # The CORE command surface every root needs — turn essentials + the operator
331
418
  # controls (pause/approve) the Studio dispatches. Registering pause_task/
332
419
  # approve_action HERE is the crux of: it retires the config.ru:28-34 patch.
@@ -411,16 +498,18 @@ module Insika
411
498
  model_visible_trace_store: Insika::ModelVisibleTraceStore.new(store: spine.backend),
412
499
  outcome_store: spine.outcome_store,
413
500
  funnel_store: spine.funnel_store, # the fold dies with the tenant
414
- followup_store: spine.followup_store,
415
- contact_store: spine.contact_store,
416
- proposal_store: spine.proposal_store,
417
- harvest_store: spine.harvest_store,
418
- task_store: spine.task_store, checkpoint_store: spine.checkpoint_store,
419
- outbox_store: spine.outbox_store,
420
- shadow_pairs: spine.shadow_pair_store,
421
- token_store: spine.token_store, # revoked BEFORE the sweep
422
- event_stream: spine.event_stream
423
- ))
501
+ followup_store: spine.followup_store,
502
+ contact_store: spine.contact_store,
503
+ proposal_store: spine.proposal_store,
504
+ harvest_store: spine.harvest_store,
505
+ schedule_store: spine.schedule_store,
506
+ artifact_store: spine.artifact_store,
507
+ task_store: spine.task_store, checkpoint_store: spine.checkpoint_store,
508
+ outbox_store: spine.outbox_store,
509
+ shadow_pairs: spine.shadow_pair_store,
510
+ token_store: spine.token_store, # revoked BEFORE the sweep
511
+ event_stream: spine.event_stream
512
+ ))
424
513
  # the operator's baseline freeze — the number
425
514
  # read. Synchronous control command, dispatched from the Studio.
426
515
  bus.register(:freeze_funnel_baseline,
@@ -434,6 +523,12 @@ module Insika
434
523
  bus.register(:cancel_followup,
435
524
  Insika::Commands::CancelFollowup.new(followup_store: spine.followup_store,
436
525
  event_stream: spine.event_stream))
526
+ # the report destination's Studio mutation — delete one
527
+ # artifact (the only write on the Artifacts tab; a bus command, like
528
+ # every Studio mutation).
529
+ bus.register(:delete_artifact,
530
+ Insika::Commands::DeleteArtifact.new(artifact_store: spine.artifact_store,
531
+ event_stream: spine.event_stream))
437
532
  bus.register(:revoke_contact,
438
533
  Insika::Commands::RevokeContact.new(contact_store: spine.contact_store,
439
534
  followup_store: spine.followup_store,
@@ -493,6 +588,23 @@ module Insika
493
588
  # code path; the engine keeps the automatic pass when the root does
494
589
  # not pass a runner here (nil = the command is not on the bus).
495
590
  bus.register(:run_distillation, run_distillation) if run_distillation
591
+ # the demo seed's ONE path — the CLI's `insika demo:seed` builds
592
+ # the same Insika::Demo::Seeder itself (no executor, no bus, from
593
+ # Graph.spine alone), and the Studio's "Seed demo data" button
594
+ # dispatches this bus command; nothing else writes the demo agent.
595
+ config_store = Insika::ConfigStore.new(store: spine.backend)
596
+ demo_seeder = Insika::Demo::Seeder.new(
597
+ profiles: profiles, store: spine.backend, session_store: spine.session_store,
598
+ task_store: spine.task_store, outcome_store: spine.outcome_store,
599
+ funnel_store: spine.funnel_store, followup_store: spine.followup_store,
600
+ refinement_store: spine.refinement_store, pending_action_store: spine.pending_action_store,
601
+ proposal_store: spine.proposal_store, memory_store: spine.memory_store,
602
+ golden_store: Insika::GoldenStore.new(config_store: config_store),
603
+ baseline_store: Insika::BaselineStore.new(config_store: config_store),
604
+ event_stream: spine.event_stream
605
+ )
606
+ bus.register(:seed_demo_data,
607
+ Insika::Commands::SeedDemoData.new(seeder: demo_seeder, event_stream: spine.event_stream))
496
608
  bus
497
609
  end
498
610
 
@@ -505,7 +617,9 @@ module Insika
505
617
  :harvest_store,
506
618
  :token_store, :budget_ledger, :circuit_state, :outbox_store, :shadow_pair_store,
507
619
  :inbound_log, :outcome_store, :funnel_store,
508
- :contact_store, :followup_store, :proposal_store,
620
+ :contact_store, :followup_store, :schedule_store, :proposal_store,
621
+ :knowledge_store,
622
+ :artifact_store,
509
623
  :code_tool_registry, :workflow_registry, :policy_registry, :capability_registry, :hooks,
510
624
  :channel_registry, keyword_init: true
511
625
  )
@@ -520,7 +634,10 @@ module Insika
520
634
  :inbound_log, :token_store,
521
635
  :budget_ledger, :circuit_state, :outcome_store, :funnel_store,
522
636
  :contact_store, :followup_store, :proposal_store,
637
+ :schedule_store,
523
638
  :harvest_store,
639
+ :knowledge_store,
640
+ :artifact_store,
524
641
  :channel_registry, :channel_delivery,
525
642
  :code_tool_registry, :tool_registry, :workflow_registry, :policy_registry, :capability_registry,
526
643
  :tool_catalog, :skill_catalog, :prompt_catalog,
@@ -531,6 +648,61 @@ module Insika
531
648
  ) do
532
649
  def durable? = backend.is_a?(Insika::Stores::SQLite)
533
650
  end
651
+
652
+ # The save_artifact tool, registered OPTIONAL on the shared code registry
653
+ # (the per-agent allowlist is the switch). The signing/base config is
654
+ # bound at registration time from the environment — the tool instance is
655
+ # per-deployment, and the tenant/agent/task bindings arrive per-turn via
656
+ # the deposited turn context.
657
+ # `run_persona_eval` (C3.1): a QA agent's own probe against a SIBLING
658
+ # agent of THIS SAME graph. Registered OPTIONAL on the shared code
659
+ # registry, same switch as `save_artifact` — but unlike that one, this
660
+ # is NOT called unconditionally from `build`: it needs a `golden_store`
661
+ # and a `settings_store` (the persona model + judge panel), and neither
662
+ # lives on the spine (the base/minimal wiring builds without either, the
663
+ # same way it builds without a Studio) — so each root calls this itself,
664
+ # once it has both. `config/deployment.rb` (round1, and the actual
665
+ # production `config.ru` root) and `DSL::Runtime` both do.
666
+ #
667
+ # `graph` is the ALREADY-RETURNED `Graph::Result` — bus/executor fully
668
+ # assembled by the time a caller has it to pass here, so `GraphChat.new`
669
+ # needs no laziness of its own (the tool's own factory block is what is
670
+ # lazy — this method just registers it).
671
+ # `llm:` — the caller's own RubyLLM::Context, when it has one
672
+ # (DSL::Runtime does; config/deployment.rb does not, and reads the
673
+ # process-wide RubyLLM constant like the rest of that root already
674
+ # does). Forwarded as-is so the tool's OWN persona/judge calls spend
675
+ # the right credentials instead of silently defaulting to nil.
676
+ def register_persona_eval_tool(graph, golden_store:, settings_store:, llm: nil)
677
+ graph.code_tool_registry.register("run_persona_eval", optional: true) do
678
+ require "ruby_llm"
679
+ require_relative "../tools/run_persona_eval"
680
+ Insika::Tools::RunPersonaEval.new(
681
+ golden_store: golden_store, profiles: graph.profiles, tool_registry: graph.tool_registry,
682
+ runtime: GraphChat.new(graph: graph), graph: graph, settings_store: settings_store,
683
+ budget_ledger: graph.budget_ledger, event_stream: graph.event_stream, llm: llm
684
+ )
685
+ end
686
+ end
687
+
688
+ def register_artifact_tool(spine)
689
+ signing_key = Insika::EnvSchema.read("INSIKA_ARTIFACT_SIGNING_KEY")
690
+ signing_ttl = Insika::EnvSchema.read("INSIKA_ARTIFACT_SIGNING_TTL")
691
+ max_bytes = Insika::EnvSchema.read("INSIKA_ARTIFACT_MAX_BYTES")
692
+ base_url = Insika::Coercion.presence(Insika::EnvSchema.read("INSIKA_PUBLIC_URL"))
693
+ spine.code_tool_registry.register("save_artifact", optional: true) do
694
+ require "ruby_llm"
695
+ require_relative "../tools/save_artifact"
696
+ Insika::Tools::SaveArtifact.new(
697
+ artifact_store: spine.artifact_store,
698
+ base_url: base_url,
699
+ signing_key: signing_key,
700
+ signing_ttl: signing_ttl&.to_i,
701
+ max_bytes: max_bytes&.to_i,
702
+ event_stream: spine.event_stream
703
+ )
704
+ end
705
+ end
534
706
  end
535
707
  end
536
708
  end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Wiring
5
+ # A synchronous, in-process "chat" seam over an assembled `Graph::Result`:
6
+ # dispatch a Command, drain the event stream to its terminal event, hand
7
+ # back the outcome. Extracted from `DSL::Runtime#chat`/`#run_workflow`
8
+ # (C3.1) so a graph built OUTSIDE the DSL — `config/deployment.rb`, the
9
+ # actual production/round1 composition root `config.ru` boots — can hand a
10
+ # `#chat`-capable object to anything that needs one (today:
11
+ # `run_persona_eval`'s `Evals::GraphTransport` seam), without a second copy
12
+ # of this dispatch+drain plumbing. `DSL::Runtime` now delegates here too —
13
+ # one implementation, exercised by both roots.
14
+ class GraphChat
15
+ TERMINAL = %i[task_completed task_failed task_cancelled].freeze
16
+
17
+ def initialize(graph:)
18
+ @graph = graph
19
+ end
20
+
21
+ # One turn, in-process -> the assistant's text. `agent:` is REQUIRED —
22
+ # this seam has no notion of "the default agent" (that is a DSL::Runtime
23
+ # concept, filled in by its own caller before delegating here). Raises
24
+ # Insika::Error on a failed/cancelled turn, or the command's own
25
+ # rejection (unknown agent, bad schema) verbatim.
26
+ def chat(message, agent:, session_id: nil, timeout: nil)
27
+ command = Insika::Command.build(
28
+ :send_message, { agent: agent.to_s, message: message, session_id: session_id },
29
+ transport: :cli
30
+ )
31
+ run_command(command, session_id: session_id, timeout: timeout)[:text]
32
+ end
33
+
34
+ # Dispatch + drain, generic over any Command — the seam a workflow run
35
+ # also needs (a :trigger_workflow Command carries :output, not :text).
36
+ # Subscribes BEFORE dispatching (the fiber may emit eagerly) and binds to
37
+ # the dispatched task. A synchronous handler error (unknown agent/
38
+ # workflow, bad input against the schema) propagates untouched — the
39
+ # caller sees the real ValidationError/NotFoundError, not a turn failure.
40
+ def run_command(command, session_id:, timeout:)
41
+ require "async"
42
+ outcome = {}
43
+ rejected = nil
44
+ Async do |task|
45
+ serving = !session_id.nil?
46
+ @graph.executor.supervised = true if serving
47
+ ensure_session(session_id) if session_id
48
+ sub = @graph.event_stream.subscribe
49
+ res = begin
50
+ @graph.bus.dispatch(command)
51
+ rescue StandardError => e
52
+ # CAPTURED, not re-raised inside the reactor: letting it escape the
53
+ # Async block logs "Task may have ended with unhandled exception" —
54
+ # alarming noise for a DOCUMENTED rejection (a schema violation, an
55
+ # unknown agent). Re-raised verbatim below, outside the reactor.
56
+ sub.close
57
+ rejected = e
58
+ next
59
+ end
60
+ sub.bind(task_id: res[:task_id])
61
+ drain(sub, outcome, task, timeout)
62
+ teardown_serving if serving
63
+ end.wait
64
+ raise rejected if rejected
65
+ raise Insika::Error, "turn #{outcome[:error]}" if outcome[:error]
66
+
67
+ outcome
68
+ end
69
+
70
+ private
71
+
72
+ # Reads the stream until this turn reaches a terminal event, then stops.
73
+ # A workflow run also carries its typed OUTPUT, which is not the turn text.
74
+ def drain(sub, outcome, task, timeout)
75
+ reader = task.async do
76
+ sub.each do |ev|
77
+ case ev.type
78
+ when :task_completed then outcome[:text] = ev.data[:content].to_s
79
+ when :workflow_completed then outcome[:output] = ev.data[:output]
80
+ when :task_failed then outcome[:error] = "failed: #{ev.data[:message] || ev.data[:error]}"
81
+ when :task_cancelled then outcome[:error] = "cancelled"
82
+ end
83
+ sub.close if TERMINAL.include?(ev.type)
84
+ end
85
+ end
86
+ timeout ? task.with_timeout(timeout) { reader.wait } : reader.wait
87
+ rescue Async::TimeoutError
88
+ outcome[:error] = "timed out after #{timeout}s"
89
+ sub.close
90
+ end
91
+
92
+ def ensure_session(session_id)
93
+ @graph.session_store.find(session_id) || @graph.session_store.create(id: session_id, vars: {})
94
+ end
95
+
96
+ def teardown_serving
97
+ @graph.executor.stop_session_actors
98
+ @graph.executor.instance_variable_get(:@supervisor)&.stop
99
+ end
100
+ end
101
+ end
102
+ end