insika 0.3.0 → 0.8.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 (204) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +296 -0
  3. data/README.md +48 -12
  4. data/bin/insika +725 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +116 -406
  7. data/docs/API.md +5 -5
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +137 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +14 -14
  12. data/docs/CONTEXT.md +63 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +87 -10
  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 +17 -29
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +46 -12
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +222 -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 +5 -5
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +23 -7
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +3 -3
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +176 -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 +99 -17
  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 +50 -19
  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/compaction.rb +196 -0
  76. data/lib/insika/context/builder.rb +6 -2
  77. data/lib/insika/context/fragment.rb +4 -1
  78. data/lib/insika/context/priority.rb +8 -0
  79. data/lib/insika/context/providers/briefing.rb +53 -24
  80. data/lib/insika/context/providers/knowledge.rb +108 -0
  81. data/lib/insika/context/providers/prompt.rb +30 -24
  82. data/lib/insika/context/providers/session.rb +46 -10
  83. data/lib/insika/context_trace_store.rb +11 -1
  84. data/lib/insika/cron.rb +189 -0
  85. data/lib/insika/demo/agent_attrs.rb +43 -0
  86. data/lib/insika/demo/golden_cases.rb +81 -0
  87. data/lib/insika/demo/seeder.rb +336 -0
  88. data/lib/insika/doctor.rb +280 -17
  89. data/lib/insika/dsl/definition.rb +3 -2
  90. data/lib/insika/dsl/runtime.rb +64 -79
  91. data/lib/insika/dsl/server_boot.rb +23 -1
  92. data/lib/insika/dsl/system.rb +10 -2
  93. data/lib/insika/dsl.rb +103 -2
  94. data/lib/insika/env_schema.rb +21 -7
  95. data/lib/insika/evals/golden.rb +41 -4
  96. data/lib/insika/evals/judge.rb +47 -2
  97. data/lib/insika/evals/pairwise.rb +11 -0
  98. data/lib/insika/evals/persona.rb +98 -0
  99. data/lib/insika/evals/runner.rb +9 -0
  100. data/lib/insika/evals/simulator.rb +225 -0
  101. data/lib/insika/evals/transport.rb +84 -2
  102. data/lib/insika/event_stream.rb +10 -0
  103. data/lib/insika/executor.rb +295 -55
  104. data/lib/insika/followup_policy.rb +2 -25
  105. data/lib/insika/golden_store.rb +16 -1
  106. data/lib/insika/grounding/matcher.rb +1 -1
  107. data/lib/insika/knowledge.rb +680 -0
  108. data/lib/insika/knowledge_store.rb +140 -0
  109. data/lib/insika/loop_detector.rb +5 -34
  110. data/lib/insika/mcp_client.rb +94 -0
  111. data/lib/insika/mcp_json.rb +74 -0
  112. data/lib/insika/mcp_live_tool.rb +43 -0
  113. data/lib/insika/mcp_store.rb +98 -26
  114. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  115. data/lib/insika/mcp_tool_registry.rb +100 -0
  116. data/lib/insika/media.rb +115 -31
  117. data/lib/insika/message_origin.rb +1 -1
  118. data/lib/insika/middleware.rb +9 -0
  119. data/lib/insika/onboarding.rb +17 -1
  120. data/lib/insika/outcome_store.rb +1 -1
  121. data/lib/insika/overlay_tool_registry.rb +37 -17
  122. data/lib/insika/packaging.rb +2 -2
  123. data/lib/insika/profile_source.rb +15 -1
  124. data/lib/insika/prompt_catalog.rb +10 -0
  125. data/lib/insika/retention.rb +36 -1
  126. data/lib/insika/router/app.rb +157 -0
  127. data/lib/insika/router/backend_pool.rb +98 -0
  128. data/lib/insika/router/hash_ring.rb +55 -0
  129. data/lib/insika/router/proxy_body.rb +34 -0
  130. data/lib/insika/router/session_key.rb +54 -0
  131. data/lib/insika/router.rb +18 -0
  132. data/lib/insika/schedule.rb +177 -0
  133. data/lib/insika/schedule_engine.rb +314 -0
  134. data/lib/insika/schedule_store.rb +208 -0
  135. data/lib/insika/server/app.rb +105 -15
  136. data/lib/insika/server/rack_app.rb +5 -1
  137. data/lib/insika/server/responses.rb +5 -5
  138. data/lib/insika/session_store.rb +34 -4
  139. data/lib/insika/settings_store.rb +8 -1
  140. data/lib/insika/skill_catalog.rb +12 -0
  141. data/lib/insika/soak/runner.rb +4 -4
  142. data/lib/insika/steer_injector.rb +21 -10
  143. data/lib/insika/studio/app.rb +591 -47
  144. data/lib/insika/studio/assets/dist/application.css +1 -1
  145. data/lib/insika/studio/assets/dist/application.js +21 -21
  146. data/lib/insika/studio/forms.rb +57 -5
  147. data/lib/insika/studio/nav_icons.rb +14 -1
  148. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  149. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  150. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  151. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  152. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  153. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  154. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  155. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  156. data/lib/insika/studio/views/_agents_master.erb +44 -0
  157. data/lib/insika/studio/views/_message.erb +49 -32
  158. data/lib/insika/studio/views/agent_detail.erb +61 -820
  159. data/lib/insika/studio/views/agents.erb +70 -57
  160. data/lib/insika/studio/views/artifact.erb +23 -0
  161. data/lib/insika/studio/views/artifacts.erb +59 -0
  162. data/lib/insika/studio/views/evals.erb +2 -2
  163. data/lib/insika/studio/views/facts.erb +1 -1
  164. data/lib/insika/studio/views/funnel.erb +1 -1
  165. data/lib/insika/studio/views/home.erb +106 -67
  166. data/lib/insika/studio/views/knowledge.erb +123 -0
  167. data/lib/insika/studio/views/layout.erb +14 -11
  168. data/lib/insika/studio/views/mcp.erb +174 -80
  169. data/lib/insika/studio/views/session.erb +231 -177
  170. data/lib/insika/studio/views/settings.erb +50 -1
  171. data/lib/insika/studio/views/skills.erb +1 -1
  172. data/lib/insika/studio/views/tools.erb +24 -9
  173. data/lib/insika/telemetry/recorder.rb +49 -1
  174. data/lib/insika/templates/browser-agent/README.md +36 -0
  175. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  176. data/lib/insika/templates/daily-digest/README.md +47 -0
  177. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  178. data/lib/insika/templates/repo-explorer/README.md +36 -0
  179. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  180. data/lib/insika/templates/research-analyst/README.md +26 -0
  181. data/lib/insika/templates/research-analyst/agent.rb +68 -0
  182. data/lib/insika/templates/review-panel/README.md +20 -0
  183. data/lib/insika/templates/review-panel/agent.rb +50 -0
  184. data/lib/insika/templates/travel-planner/README.md +35 -0
  185. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  186. data/lib/insika/templates.rb +112 -0
  187. data/lib/insika/tick.rb +24 -12
  188. data/lib/insika/timezone.rb +45 -0
  189. data/lib/insika/tool_batch.rb +67 -0
  190. data/lib/insika/tool_usage_report.rb +162 -0
  191. data/lib/insika/tools/generate_image.rb +52 -7
  192. data/lib/insika/tools/load_knowledge.rb +74 -0
  193. data/lib/insika/tools/run_persona_eval.rb +328 -0
  194. data/lib/insika/tools/save_artifact.rb +95 -0
  195. data/lib/insika/turn_budget.rb +91 -0
  196. data/lib/insika/turn_output.rb +1 -1
  197. data/lib/insika/turn_state.rb +15 -4
  198. data/lib/insika/version.rb +1 -1
  199. data/lib/insika/wiring/graph.rb +184 -12
  200. data/lib/insika/wiring/graph_chat.rb +102 -0
  201. data/lib/insika.rb +64 -0
  202. metadata +109 -5
  203. data/docs/build.md +0 -14
  204. data/docs/understand.md +0 -10
@@ -128,6 +128,12 @@ module Insika
128
128
  # event the consumer acts on). Same opt-in as
129
129
  # `memory`. What "stuck" MEANS is the consumer's call
130
130
  # (escalation via CRM/operator), never the engine's.
131
+ :stt_prompt, # STT vocabulary hint (WS9): domain words
132
+ # (product names, brand terms) the transcriber should
133
+ # expect on this agent's voice notes — passed straight
134
+ # through to the Whisper-family provider's `prompt:`.
135
+ # nil/absent = the deployment default (INSIKA_STT_PROMPT
136
+ # env) or nothing. OPERATOR config, never customer input.
131
137
  :outputs, # generated-media output policy (WS9, saída):
132
138
  # { "image" => { "model" => …, "size" => "1024x1024" },
133
139
  # "tts" => { "model" => "tts-1", "voice" => "alloy",
@@ -244,20 +250,50 @@ module Insika
244
250
  # off (parity, byte-identical engine). Shape-validated by
245
251
  # the command/engine, never here (the refinement precedent).
246
252
  # Deep-stringified like the other free-form hashes.
247
- :harvest # the gated-harvest declaration — pack data,
248
- # exactly like refinement/distill:
249
- # { "enabled" => bool,
250
- # "negative_list" => [ { "rule" => "…", "pattern" => "…",
251
- # "note" => "…" } ],
252
- # "miner" => { "model" => "<ref — absent = the platform
253
- # utility_model>", "window" => { "last_sessions" => N },
254
- # "max_proposals" => N, "budget" => { "tokens" => N } },
255
- # "idle_hours" => 24, "min_messages" => 3 }.
256
- # The ENGINE mines (reads sessions, asks the miner, filters
257
- # through the negative list + grounding), never authors a
258
- # rule (D4). nil/absent = the loop is off (parity).
259
- # Shape-validated by the command/engine/doctor, never here.
253
+ :harvest, # the gated-harvest declaration — pack data,
254
+ # exactly like refinement/distill:
255
+ # { "enabled" => bool,
256
+ # "negative_list" => [ { "rule" => "…", "pattern" => "…",
257
+ # "note" => "…" } ],
258
+ # "miner" => { "model" => "<ref — absent = the platform
259
+ # utility_model>", "window" => { "last_sessions" => N },
260
+ # "max_proposals" => N, "budget" => { "tokens" => N } },
261
+ # "idle_hours" => 24, "min_messages" => 3 }.
262
+ # The ENGINE mines (reads sessions, asks the miner, filters
263
+ # through the negative list + grounding), never authors a
264
+ # rule (D4). nil/absent = the loop is off (parity).
265
+ # Shape-validated by the command/engine/doctor, never here.
266
+ # Deep-stringified like the other free-form hashes.
267
+ :knowledge, # the post-turn learning declaration — pack
268
+ # data, exactly like distill/harvest:
269
+ # { "extract" => true, "retrieve" => true,
270
+ # "model" => "<ref — absent = the platform
271
+ # utility_model>", "top_k" => 5,
272
+ # "index" => "scan", "types" => ["fact", …] }.
273
+ # The ENGINE extracts concepts after the turn and
274
+ # stamps their provenance/confidence/sources;
275
+ # the model only names concepts (same D1
276
+ # discipline as distill). nil/absent = the
277
+ # loop is off (parity). Shape-validated by
278
+ # the extractor/doctor, never here.
260
279
  # Deep-stringified like the other free-form hashes.
280
+ :schedules # the recurring-schedule declarations — pack
281
+ # data, exactly like followup/distill:
282
+ # [ { "id" => "daily_report",
283
+ # "cron" | "every" => …,
284
+ # "tz" => "America/Sao_Paulo",
285
+ # "message" => "<the synthetic inbound>",
286
+ # "session_mode" => "new"|"fixed",
287
+ # "overrides" => { "turn_timeout" => N,
288
+ # "max_tool_calls" => N,
289
+ # "model" => … },
290
+ # "enabled" => bool }, … ].
291
+ # The ENGINE owns the firing (the
292
+ # ScheduleEngine, the tick's duty); the
293
+ # store rows are declared-derived.
294
+ # Shape-validated by Insika::Schedule,
295
+ # never here. nil/empty = the feature is
296
+ # off for that agent (parity).
261
297
  )
262
298
 
263
299
  # Reopened class (not a Data.define block): a constant assigned inside
@@ -289,15 +325,17 @@ module Insika
289
325
  params: {}, model_policy: nil, guardrails: nil, sandbox: nil,
290
326
  refinement: nil, capabilities_declared: nil, edge_stream: nil, metadata: {},
291
327
  budget: nil, reliability: nil, alerts: nil, routes: nil, stuck_signal: nil,
292
- outputs: nil, briefing_fields: nil, grounding: nil, funnel: nil,
293
- followup: nil, distill: nil, harvest: nil)
328
+ outputs: nil, stt_prompt: nil, briefing_fields: nil, grounding: nil, funnel: nil,
329
+ followup: nil, distill: nil, harvest: nil, knowledge: nil, schedules: nil)
294
330
  new(
295
331
  id: id, model: model, provider: provider, base_prompt: base_prompt,
296
332
  prompt_files: Array(prompt_files), tools_allow: tools_allow,
297
333
  tools_deny: Array(tools_deny), tools_allow_groups: tools_allow_groups, skills: skills,
298
334
  skills_eager: skills_eager,
299
335
  context_providers: context_providers, workflows_allow: workflows_allow,
300
- policies: Array(policies), prompt_refs: Array(prompt_refs),
336
+ policies: normalize_policies(policies, tools_allow: tools_allow, tools_deny: tools_deny,
337
+ tools_allow_groups: tools_allow_groups),
338
+ prompt_refs: Array(prompt_refs),
301
339
  limits: DEFAULT_LIMITS.merge(limits), approvals_required: approvals_required,
302
340
  capabilities: capabilities,
303
341
  # opt-in like capabilities: nil => NONE. Array-normalize a present value so
@@ -326,6 +364,9 @@ module Insika
326
364
  routes: Coercion.deep_stringify(routes),
327
365
  stuck_signal: stuck_signal,
328
366
  outputs: Coercion.deep_stringify(outputs),
367
+ # plain vocabulary string, like `base_prompt` — no deep_stringify (not
368
+ # a Hash/Array). "" round-trips as nil (Coercion.presence).
369
+ stt_prompt: Coercion.presence(stt_prompt),
329
370
  # Flat [String] — same discipline as capabilities_declared: a
330
371
  # symbol/string mix would be a silent miss in the provider's known-set.
331
372
  briefing_fields: normalize_briefing_fields(briefing_fields),
@@ -348,10 +389,51 @@ module Insika
348
389
  # harvest is profile DATA, deep-stringified like the other
349
390
  # free-form hashes; shape-validated by the command/engine/doctor
350
391
  # (never here — the refinement precedent). nil = off (parity).
351
- harvest: Coercion.deep_stringify(harvest)
392
+ harvest: Coercion.deep_stringify(harvest),
393
+ # knowledge is profile DATA, deep-stringified like the other
394
+ # free-form hashes; shape-validated by the extractor/doctor
395
+ # (never here — the refinement precedent). nil = off (parity).
396
+ knowledge: Coercion.deep_stringify(knowledge),
397
+ # schedules is profile DATA, deep-stringified like the other
398
+ # free-form hashes (an ARRAY of declarations); parsed into
399
+ # Insika::Schedule entries by the engine/doctor/Studio (shape-validated
400
+ # THERE, never here). nil/[] = the feature is off (parity).
401
+ schedules: normalize_schedules(schedules)
352
402
  )
353
403
  end
354
404
 
405
+ # Declaring a tool allow/deny list IS opting into it. The list is only ever
406
+ # applied by the builtin `tool_allowlist` policy, and the Policy::Engine runs
407
+ # ONLY the policies a profile names — so a profile with `tools_allow: [a, b]`
408
+ # and no policies sent EVERY registered tool to the model, silently. A
409
+ # declared allowlist that does nothing is the failure mode; same rule as the
410
+ # "mcp:<name>" group auto-added to `tools_allow_groups` by the DSL.
411
+ #
412
+ # Presence, not emptiness, is the trigger for the two nil-able lists:
413
+ # `tools_allow: []` means "no tools" and must enforce just as hard.
414
+ # `tools_deny` has no nil state (it defaults to []), so only a non-empty
415
+ # deny list counts as a declaration.
416
+ #
417
+ # Appended, never prepended: profile-declared policies keep their order. The
418
+ # engine intersects allows and unions denies, so position changes only the
419
+ # audit order, not the outcome.
420
+ def self.normalize_policies(policies, tools_allow:, tools_deny:, tools_allow_groups:)
421
+ names = Array(policies)
422
+ declared = !tools_allow.nil? || !tools_allow_groups.nil? || !Array(tools_deny).empty?
423
+ return names unless declared && names.none? { |n| n.to_s == "tool_allowlist" }
424
+
425
+ names + ["tool_allowlist"]
426
+ end
427
+
428
+ # nil/absent -> nil; a single Hash -> [Hash]; else an Array of Hashes —
429
+ # deep-stringified so JSON round-trips stay stable.
430
+ def self.normalize_schedules(list)
431
+ return nil if list.nil?
432
+
433
+ entries = list.is_a?(Hash) ? [list] : Array(list)
434
+ entries.empty? ? nil : Coercion.deep_stringify(entries)
435
+ end
436
+
355
437
  # nil -> []; strings; trim + drop empties + uniq (stable order); every name
356
438
  # must match ToolDefinition::NAME_RE (\A[a-z][a-z0-9_]*\z) or it is a
357
439
  # ValidationError at build time — the names become tool-description text,
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "time"
5
+
6
+ module Insika
7
+ # The signed-link half of the artifact serving surface. The signing key
8
+ # lives in the environment (INSIKA_ARTIFACT_SIGNING_KEY), never in a store:
9
+ # HMAC-SHA256 over (id, expiry), verified in constant time on the serve
10
+ # path. Without the key, only the authenticated Studio URL exists.
11
+ #
12
+ # The token is deterministic for a (id, expiry) pair — no nonce, on purpose:
13
+ # a rotated key invalidates every outstanding link, which is the documented
14
+ # rotation behavior (an artifact link is short-lived by TTL, not by
15
+ # unguessability of a single-use nonce).
16
+ module ArtifactSigning
17
+ module_function
18
+
19
+ # The route's path shapes — the ONE place the URL grammar lives, shared
20
+ # by the tool (which hands URLs to the model) and the route (which serves
21
+ # them).
22
+ AUTHENTICATED_PATH = "/studio/artifacts/%{id}/content"
23
+ SIGNED_PATH = "/studio/artifacts/s/%{id}?exp=%{exp}&sig=%{sig}"
24
+
25
+ # -> hex token (64 chars) | nil when the key is blank (no signed surface).
26
+ def sign(id:, expires_at:, key:)
27
+ key = key.to_s
28
+ return nil if key.empty?
29
+
30
+ OpenSSL::HMAC.hexdigest("SHA256", key, payload(id, expires_at))
31
+ end
32
+
33
+ # -> bool. Re-signs the (id, exp) pair the route extracted from the URL
34
+ # and compares in constant time; an expired link or a blank key/token is
35
+ # false (the route 404s — no oracle). Expiry is inclusive: a link at its
36
+ # exact `expires_at` still verifies.
37
+ def valid?(id:, token:, key:, exp:, now: Time.now.utc)
38
+ token = token.to_s
39
+ key = key.to_s
40
+ return false if key.empty? || token.empty?
41
+
42
+ expected = sign(id: id.to_s, expires_at: exp, key: key)
43
+ return false unless expected && secure_compare(expected, token)
44
+
45
+ exp_time = exp.is_a?(Time) ? exp.to_time.utc : Time.iso8601(exp.to_s).utc
46
+ now.to_time <= exp_time
47
+ rescue ArgumentError, TypeError
48
+ false # a malformed exp (or a time that never parses) is an invalid link
49
+ end
50
+
51
+ # -> the artifact's shareable URL. With a key + ttl: the signed link
52
+ # (shares OUTSIDE the Studio, expires). Without: the authenticated Studio
53
+ # content URL. An empty base yields the relative path — still openable in
54
+ # the Studio, useless on a channel (the doc says exactly that).
55
+ def url_for(id:, base: nil, key: nil, ttl: nil, now: Time.now.utc)
56
+ base = base.to_s.sub(%r{/\z}, "")
57
+ if key && key.to_s.length.positive? && ttl && ttl.to_i.positive?
58
+ exp = (now.to_time + ttl.to_i).utc.iso8601
59
+ sig = sign(id: id.to_s, expires_at: exp, key: key)
60
+ "#{base}#{format(SIGNED_PATH, id: id, exp: exp, sig: sig)}"
61
+ else
62
+ "#{base}#{format(AUTHENTICATED_PATH, id: id)}"
63
+ end
64
+ end
65
+
66
+ # Constant-time comparison of two hex strings. Length-independent compare
67
+ # is fine here: the token length is public (fixed by the algorithm).
68
+ def secure_compare(a, b)
69
+ return false unless a.bytesize == b.bytesize
70
+
71
+ a.bytes.zip(b.bytes).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero?
72
+ end
73
+
74
+ # The signed payload: id + expiry — both are what the route must not let
75
+ # an attacker change. The id is the store key; the expiry bounds the link.
76
+ # Accepts a Time or an ISO8601 String (the route passes the query param).
77
+ def payload(id, expires_at)
78
+ time = expires_at.is_a?(Time) ? expires_at.utc : Time.iso8601(expires_at.to_s).utc
79
+ "#{id}:#{time.iso8601}"
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # The report destination: one record per run, no versioning, the listing
8
+ # IS the history. A store, not a CMS.
9
+ #
10
+ # The tenant is a BINDING of the row — inherited from the agent that saved
11
+ # the artifact (the tool reads it from the turn context, never from the
12
+ # model), so a purge is a tenant-prefix scan and store A's report can never
13
+ # appear in, or be linked from, store B.
14
+ #
15
+ # Record key: "<tenant>:<agent>:<id>" — per-tenant / per-agent scans are
16
+ # prefixes; the id is the LAST segment, so a find is a suffix match (the
17
+ # followup_store.rb idiom — there is no stable prefix for an id alone).
18
+ # Blank tenant -> the literal "platform" (the outcome_store.rb rule, so the
19
+ # purge prefix scans line up).
20
+ class ArtifactStore
21
+ SCOPE = "artifacts"
22
+
23
+ # The mime allowlist — a page, not an attachment: no binaries, no uploads.
24
+ MIMES = %w[text/html text/markdown image/svg+xml].freeze
25
+
26
+ # The size cap on `content` (INSIKA_ARTIFACT_MAX_BYTES; the audio-message
27
+ # precedent is 1 MB — an artifact is a page, not an attachment).
28
+ DEFAULT_MAX_BYTES = 1_000_000
29
+ TITLE_MAX = 200
30
+
31
+ Record = Data.define(:id, :tenant, :agent, :task_id, :title, :mime,
32
+ :content, :created_at)
33
+
34
+ def initialize(store:)
35
+ @store = store
36
+ end
37
+
38
+ # -> Record. Validates the mime allowlist, a non-empty title (<= 200
39
+ # chars) and content within `max_bytes` — ValidationError otherwise (the
40
+ # tool returns it to the model as `{ error: }`).
41
+ def create(tenant:, agent:, task_id:, title:, mime:, content:,
42
+ id: SecureRandom.uuid, now: Time.now.utc, max_bytes: DEFAULT_MAX_BYTES)
43
+ mime = "text/html" if mime.to_s.empty?
44
+ raise Insika::ValidationError, "mime must be one of #{MIMES.join(', ')}, got #{mime.inspect}" unless MIMES.include?(mime.to_s)
45
+
46
+ title = title.to_s
47
+ raise Insika::ValidationError, "title is required (1..#{TITLE_MAX} chars)" if title.strip.empty? || title.length > TITLE_MAX
48
+
49
+ content = content.to_s
50
+ raise Insika::ValidationError, "content is required" if content.empty?
51
+ raise Insika::ValidationError, "content exceeds #{max_bytes} bytes (#{content.bytesize})" if content.bytesize > max_bytes
52
+
53
+ record = { "id" => id.to_s, "tenant" => tenant_id(tenant), "agent" => agent.to_s,
54
+ "task_id" => task_id.to_s, "title" => title, "mime" => mime.to_s,
55
+ "content" => content, "created_at" => now.iso8601 }
56
+ @store.set(SCOPE, key(record), record)
57
+ to_record(record)
58
+ end
59
+
60
+ # -> Record | nil. Suffix match over the scope's keys (no stable prefix
61
+ # for an id alone — the followup_store.rb idiom).
62
+ def find(id)
63
+ key = @store.list(SCOPE).find { |k| k.end_with?(":#{id}") }
64
+ key && to_record(@store.get(SCOPE, key))
65
+ end
66
+
67
+ # -> [Record] — one agent's artifacts, newest first (the listing IS the
68
+ # history; the Studio tab's read).
69
+ def for_agent(tenant:, agent:)
70
+ prefix = "#{tenant_id(tenant)}:#{agent}:"
71
+ @store.list(SCOPE).filter_map do |k|
72
+ next unless k.start_with?(prefix)
73
+
74
+ to_record(@store.get(SCOPE, k))
75
+ end.sort_by { |r| [r.created_at.to_s, r.id] }.reverse
76
+ end
77
+
78
+ # -> [Record] — EVERY agent's artifacts for the tenant, newest first (same
79
+ # prefix scan as #purge, but reads instead of deletes). Needs the CALLER
80
+ # to know the right tenant string — see #all below for the Studio, which
81
+ # doesn't.
82
+ def for_tenant(tenant:)
83
+ prefix = "#{tenant_id(tenant)}:"
84
+ @store.list(SCOPE).filter_map do |k|
85
+ next unless k.start_with?(prefix)
86
+
87
+ to_record(@store.get(SCOPE, k))
88
+ end.sort_by { |r| [r.created_at.to_s, r.id] }.reverse
89
+ end
90
+
91
+ # -> [Record] — every artifact in the store, optionally narrowed to one
92
+ # agent, with NO tenant guess. For the Studio only: it is already
93
+ # operator-only (sees every tenant's agents), and unlike #for_agent/
94
+ # #for_tenant (hot, tenant-scoped runtime paths that a real multi-tenant
95
+ # caller uses because it KNOWS its own tenant) the Studio often does not
96
+ # — `save_artifact` binds tenant from the turn (`agent` when the Playground
97
+ # dispatched it, `"platform"` for a plain single-tenant API turn), so a
98
+ # page guessing one fixed string is wrong for the other half of the time.
99
+ # Filtering by the record's own `agent` field sidesteps the guess entirely.
100
+ def all(agent: nil)
101
+ @store.list(SCOPE).filter_map do |k|
102
+ record = to_record(@store.get(SCOPE, k))
103
+ next if record.nil?
104
+ next if agent && record.agent != agent.to_s
105
+
106
+ record
107
+ end.sort_by { |r| [r.created_at.to_s, r.id] }.reverse
108
+ end
109
+
110
+ # -> bool (did it exist?).
111
+ def delete(id)
112
+ key = @store.list(SCOPE).find { |k| k.end_with?(":#{id}") }
113
+ key ? @store.delete(SCOPE, key) : false
114
+ end
115
+
116
+ # -> count removed. The tenant-erasure reach — one tenant's artifacts die
117
+ # with it, never a neighbour's.
118
+ def purge(tenant:)
119
+ prefix = "#{tenant_id(tenant)}:"
120
+ keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
121
+ keys.each { |k| @store.delete(SCOPE, k) }
122
+ keys.size
123
+ end
124
+
125
+ # -> count removed. The retention knob's reach (`artifact_ttl_days`) —
126
+ # the guarantee that PII inside a report expires even though no reader
127
+ # can see inside the opaque HTML.
128
+ def delete_older_than(time)
129
+ cutoff = time.utc.iso8601
130
+ removed = 0
131
+ @store.list(SCOPE).each do |k|
132
+ record = @store.get(SCOPE, k)
133
+ next unless record && record["created_at"].to_s < cutoff
134
+
135
+ @store.delete(SCOPE, k)
136
+ removed += 1
137
+ end
138
+ removed
139
+ end
140
+
141
+ private
142
+
143
+ def key(record)
144
+ "#{record['tenant']}:#{record['agent']}:#{record['id']}"
145
+ end
146
+
147
+ def tenant_id(tenant)
148
+ t = tenant.to_s
149
+ t.empty? ? "platform" : t
150
+ end
151
+
152
+ def to_record(rec)
153
+ return nil if rec.nil?
154
+
155
+ Record.new(id: rec["id"], tenant: rec["tenant"], agent: rec["agent"],
156
+ task_id: rec["task_id"], title: rec["title"], mime: rec["mime"],
157
+ content: rec["content"], created_at: rec["created_at"])
158
+ end
159
+ end
160
+ end
@@ -17,7 +17,7 @@ module Insika
17
17
  # party with outages, so "keep trying" is a real requirement and "keep trying
18
18
  # forever" is a real outage of ours.
19
19
  #
20
- # It is NOT a job queue: no scheduler, no priorities, no fan-out. The moment it
20
+ # It is NOT a job queue: no priorities, no fan-out. The moment it
21
21
  # grows one, the thing to do is take a real queue, not to finish building this.
22
22
  class ChannelDelivery
23
23
  MAX_ATTEMPTS = 3
@@ -15,7 +15,7 @@ module Insika
15
15
  def initialize(tool_registry:, skill_catalog:, checkpoint_store:, event_stream:,
16
16
  hooks:, tool_catalog: nil, memory_store: nil, subagent_runner: nil,
17
17
  tool_trace_store: nil, media_runner: nil, session_store: nil,
18
- contact_store: nil, followup_store: nil)
18
+ contact_store: nil, followup_store: nil, knowledge_store: nil)
19
19
  @tool_registry = tool_registry
20
20
  @skill_catalog = skill_catalog
21
21
  @checkpoint_store = checkpoint_store
@@ -23,6 +23,10 @@ module Insika
23
23
  @hooks = hooks
24
24
  @tool_catalog = tool_catalog
25
25
  @memory_store = memory_store
26
+ # load_knowledge is the knowledge-read system tool — wired only with
27
+ # @knowledge_store present AND profile.knowledge["retrieve"] (a double
28
+ # gate, like remember). nil = never wired (parity).
29
+ @knowledge_store = knowledge_store
26
30
  # only to trace load_skill, which is not enveloped — nil = no trace (parity).
27
31
  @tool_trace_store = tool_trace_store
28
32
  # the object exposing #run_subagent (the Executor). nil = the
@@ -104,6 +108,15 @@ module Insika
104
108
  event_stream: @event_stream, state: state)
105
109
  end
106
110
 
111
+ # load_knowledge is a system default (outside the allowlist), like
112
+ # load_skill — wired only with @knowledge_store present AND the
113
+ # profile's opt-in (double gate, like remember). Reads the same
114
+ # (agent, tenant) scope the Knowledge context provider searches.
115
+ if @knowledge_store && Coercion.truthy?(state.profile.knowledge&.dig("retrieve"))
116
+ tools << Tools::LoadKnowledge.new(@knowledge_store, state.profile.id, tenant: state.tenant,
117
+ trace_recorder: @tool_trace_store, state: state)
118
+ end
119
+
107
120
  # update_briefing / set_next_step are the briefing-write system tools
108
121
  # — wired only with @session_store present AND
109
122
  # profile.briefing_fields non-empty (double gate, like remember). Never
@@ -319,20 +332,29 @@ module Insika
319
332
  end
320
333
 
321
334
  # RubyLLM's additive callbacks become events. load_skill becomes
322
- # :skill_activated. Adds the max_tool_calls counter: the loop is RubyLLM's;
323
- # here we only count and abort.
335
+ # :skill_activated. Hangs the turn's TurnBudget on them: the loop is
336
+ # RubyLLM's; here we only count, warn and abort.
324
337
  def wire_callbacks(chat, state, emit)
325
- # Per-TURN counter: safe as a closure local even under concurrent tool calls
326
- # (MRI fibers do not preempt between the read and the write). The per-CALL
327
- # correlation is NOT — it lives in fiber storage behind TurnState, because
328
- # each call gets its own fiber once tool concurrency is on.
329
- tool_calls = 0
330
- max_tool_calls = state.profile.limits[:max_tool_calls] || 50
338
+ # The turn's ONE tool-call counter. Safe as a closure-held object even
339
+ # under concurrent tool calls (MRI fibers do not preempt between the read
340
+ # and the write). The per-CALL correlation is NOT — it lives in fiber
341
+ # storage behind TurnState, because each call gets its own fiber once tool
342
+ # concurrency is on.
343
+ #
344
+ # It counts, warns at 10/5/2 calls remaining, and raises the
345
+ # `stage: :tool_limit` abort — the guard-rail that used to be inline here.
346
+ # Announcing the budget needs #add_message at the batch boundary; a chat
347
+ # without it (smoke shim, minimal double) still gets the count and the
348
+ # abort, just no notice.
349
+ budget = Insika::TurnBudget.new(
350
+ chat: chat, max: state.profile.limits[:max_tool_calls] || 50, emit: emit
351
+ )
331
352
 
332
353
  # the loop detector. Needs #after_message + #add_message for the
333
354
  # batch-boundary intervention; a chat without them (smoke shim, minimal
334
355
  # double) stays bounded by max_tool_calls alone — never half-wired.
335
- detector = if %i[after_message add_message].all? { |m| chat.respond_to?(m) }
356
+ appendable = %i[after_message add_message].all? { |m| chat.respond_to?(m) }
357
+ detector = if appendable
336
358
  repeat = state.profile.limits[:max_tool_repeat] || Insika::AgentProfile::DEFAULT_LIMITS[:max_tool_repeat]
337
359
  Insika::LoopDetector.new(chat: chat, limit: repeat, emit: emit) if repeat >= 2
338
360
  end
@@ -342,11 +364,7 @@ module Insika
342
364
  state.current_tool_call = tool_call
343
365
  # max_tool_calls guard-rail: stays inline (not as a registered hook)
344
366
  # because Hooks is shared across turns and has no unregister.
345
- tool_calls += 1
346
- if tool_calls > max_tool_calls
347
- raise Insika::TimeoutError.new("tool call limit exceeded (#{max_tool_calls})",
348
- stage: :tool_limit)
349
- end
367
+ budget.tool_call
350
368
 
351
369
  # AFTER the count, BEFORE the call runs — a post-warning
352
370
  # repeat raises here, so the stubborn loop pays for no extra call.
@@ -362,9 +380,16 @@ module Insika
362
380
  # under concurrency `after_tool_result` labelled every result with whichever
363
381
  # call started last.
364
382
  state.current_tool_name = tool_call.name.to_s
365
- if state.current_tool_name == "load_skill"
383
+ case state.current_tool_name
384
+ when "load_skill"
366
385
  args = tool_call.arguments || {}
367
386
  emit.call(:skill_activated, { name: args["name"] || args[:name] })
387
+ when "load_knowledge"
388
+ # The adoption risk is the model never calling this tool, not what
389
+ # it injects — so the metric here is retrieval CALLS, tracked at
390
+ # the same point :skill_activated is.
391
+ args = tool_call.arguments || {}
392
+ emit.call(:knowledge_retrieved, { name: args["name"] || args[:name], agent: state.profile.id })
368
393
  else
369
394
  emit.call(:tool_call, { name: tool_call.name, arguments: tool_call.arguments })
370
395
  end
@@ -374,13 +399,19 @@ module Insika
374
399
  # the RAW result — the only place a Tool::Halt (halt_when) is
375
400
  # still recognizable, and a halted batch must receive no intervention.
376
401
  detector&.tool_result(result)
402
+ budget.tool_result(result)
377
403
  result = @hooks.run_after(:tool, result)
378
404
  emit.call(:tool_result, { name: state.current_tool_name, result: result.to_s })
379
405
  end
380
406
 
381
- # the intervention appends at the batch boundary (the Nth tool
382
- # result closing) — never between two tool results of one batch.
383
- chat.after_message { |message| detector.message_ended(message) } if detector
407
+ # both appends land at the batch boundary (the Nth tool result closing) —
408
+ # never between two tool results of one batch.
409
+ return unless appendable
410
+
411
+ chat.after_message do |message|
412
+ detector&.message_ended(message)
413
+ budget.message_ended(message)
414
+ end
384
415
  end
385
416
  end
386
417
  end
@@ -13,9 +13,9 @@ module Insika
13
13
  tools_allow_groups skills skills_eager context_providers workflows_allow policies
14
14
  prompt_refs limits approvals_required capabilities subagents tools_deferred
15
15
  memory prompt_caching tool_persistence tool_output_compression budget reliability alerts
16
- routes stuck_signal outputs briefing_fields grounding funnel followup
16
+ routes stuck_signal outputs stt_prompt briefing_fields grounding funnel followup
17
17
  params model_policy guardrails refinement capabilities_declared
18
- edge_stream metadata distill harvest].freeze
18
+ edge_stream metadata distill harvest knowledge schedules].freeze
19
19
 
20
20
  module_function
21
21