insika 0.2.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 (300) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +325 -17
  3. data/README.md +52 -11
  4. data/bin/insika +895 -8
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +229 -163
  7. data/docs/API.md +56 -0
  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 +111 -21
  12. data/docs/CONTEXT.md +132 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +75 -4
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +146 -2
  17. data/docs/FACTS.md +135 -0
  18. data/docs/HARVEST.md +116 -0
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +16 -10
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +65 -6
  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 +17 -12
  27. data/docs/RELEASING.md +27 -0
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +6 -6
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +79 -2
  32. data/docs/SKILLS.md +13 -4
  33. data/docs/SOAK.md +127 -0
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +220 -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 +115 -0
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +9 -6
  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 +202 -10
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/balloon_splitter.rb +102 -0
  62. data/lib/insika/budget_ledger.rb +34 -3
  63. data/lib/insika/cache_series_store.rb +49 -0
  64. data/lib/insika/channel_delivery.rb +119 -14
  65. data/lib/insika/channels/relay.rb +77 -3
  66. data/lib/insika/channels/web.rb +2 -2
  67. data/lib/insika/chat_builder.rb +105 -2
  68. data/lib/insika/checkpoint_store.rb +16 -0
  69. data/lib/insika/commands/agent_payload.rb +3 -3
  70. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  71. data/lib/insika/commands/cancel_followup.rb +49 -0
  72. data/lib/insika/commands/delete_artifact.rb +35 -0
  73. data/lib/insika/commands/delete_concept.rb +34 -0
  74. data/lib/insika/commands/delete_mcp.rb +6 -2
  75. data/lib/insika/commands/delete_tenant_data.rb +107 -0
  76. data/lib/insika/commands/export_customer_memory.rb +48 -0
  77. data/lib/insika/commands/forget_customer.rb +117 -0
  78. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  79. data/lib/insika/commands/gate_harvest.rb +138 -0
  80. data/lib/insika/commands/gate_refinement.rb +1 -1
  81. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  82. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  83. data/lib/insika/commands/memory_put_fact.rb +23 -4
  84. data/lib/insika/commands/promote_harvest.rb +130 -0
  85. data/lib/insika/commands/record_outcome.rb +46 -0
  86. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  87. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  88. data/lib/insika/commands/reject_harvest.rb +38 -0
  89. data/lib/insika/commands/resolve_proposal.rb +108 -0
  90. data/lib/insika/commands/restore_concept.rb +34 -0
  91. data/lib/insika/commands/revoke_contact.rb +49 -0
  92. data/lib/insika/commands/rollback_harvest.rb +86 -0
  93. data/lib/insika/commands/run_distillation.rb +186 -0
  94. data/lib/insika/commands/run_harvest.rb +393 -0
  95. data/lib/insika/commands/seed_demo_data.rb +31 -0
  96. data/lib/insika/commands/send_message.rb +103 -6
  97. data/lib/insika/commands/session_purge.rb +67 -0
  98. data/lib/insika/commands/upsert_mcp.rb +6 -3
  99. data/lib/insika/commands/write_concept.rb +57 -0
  100. data/lib/insika/contact_store.rb +183 -0
  101. data/lib/insika/context/builder.rb +21 -3
  102. data/lib/insika/context/fragment.rb +7 -3
  103. data/lib/insika/context/priority.rb +5 -0
  104. data/lib/insika/context/provider.rb +17 -3
  105. data/lib/insika/context/providers/briefing.rb +96 -0
  106. data/lib/insika/context/providers/knowledge.rb +108 -0
  107. data/lib/insika/context/providers/memory.rb +16 -7
  108. data/lib/insika/context/providers/prompt.rb +57 -23
  109. data/lib/insika/context/providers/skill.rb +2 -0
  110. data/lib/insika/context/providers/tool_search.rb +2 -0
  111. data/lib/insika/context_trace_store.rb +38 -2
  112. data/lib/insika/cron.rb +189 -0
  113. data/lib/insika/demo/agent_attrs.rb +43 -0
  114. data/lib/insika/demo/golden_cases.rb +81 -0
  115. data/lib/insika/demo/seeder.rb +336 -0
  116. data/lib/insika/distill.rb +224 -0
  117. data/lib/insika/distill_engine.rb +169 -0
  118. data/lib/insika/doctor.rb +889 -11
  119. data/lib/insika/dsl/definition.rb +3 -2
  120. data/lib/insika/dsl/runtime.rb +69 -82
  121. data/lib/insika/dsl/server_boot.rb +92 -1
  122. data/lib/insika/dsl/system.rb +10 -2
  123. data/lib/insika/dsl.rb +186 -5
  124. data/lib/insika/edge_limiter.rb +28 -7
  125. data/lib/insika/env_schema.rb +25 -3
  126. data/lib/insika/errors.rb +11 -0
  127. data/lib/insika/evals/assertions.rb +3 -2
  128. data/lib/insika/evals/golden.rb +41 -4
  129. data/lib/insika/evals/judge.rb +47 -2
  130. data/lib/insika/evals/pairwise.rb +29 -6
  131. data/lib/insika/evals/persona.rb +98 -0
  132. data/lib/insika/evals/runner.rb +9 -0
  133. data/lib/insika/evals/simulator.rb +225 -0
  134. data/lib/insika/evals/transport.rb +83 -1
  135. data/lib/insika/event_stream.rb +10 -0
  136. data/lib/insika/evidence.rb +183 -0
  137. data/lib/insika/executor.rb +869 -76
  138. data/lib/insika/followup_engine.rb +207 -0
  139. data/lib/insika/followup_policy.rb +198 -0
  140. data/lib/insika/followup_store.rb +306 -0
  141. data/lib/insika/funnel_declaration.rb +106 -0
  142. data/lib/insika/funnel_fold.rb +179 -0
  143. data/lib/insika/funnel_store.rb +163 -0
  144. data/lib/insika/golden_store.rb +17 -2
  145. data/lib/insika/grounding/matcher.rb +69 -0
  146. data/lib/insika/grounding.rb +44 -0
  147. data/lib/insika/harvest/conversion_gate.rb +159 -0
  148. data/lib/insika/harvest/criterion.rb +98 -0
  149. data/lib/insika/harvest/gate.rb +194 -0
  150. data/lib/insika/harvest/negative_list.rb +199 -0
  151. data/lib/insika/harvest.rb +241 -0
  152. data/lib/insika/harvest_engine.rb +193 -0
  153. data/lib/insika/harvest_store.rb +548 -0
  154. data/lib/insika/knowledge.rb +680 -0
  155. data/lib/insika/knowledge_store.rb +140 -0
  156. data/lib/insika/mcp_client.rb +94 -0
  157. data/lib/insika/mcp_json.rb +74 -0
  158. data/lib/insika/mcp_live_tool.rb +43 -0
  159. data/lib/insika/mcp_store.rb +98 -26
  160. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  161. data/lib/insika/mcp_tool_registry.rb +100 -0
  162. data/lib/insika/media.rb +382 -0
  163. data/lib/insika/memory_audit_store.rb +85 -0
  164. data/lib/insika/memory_store.rb +264 -23
  165. data/lib/insika/message_origin.rb +7 -2
  166. data/lib/insika/middleware.rb +9 -0
  167. data/lib/insika/model_visible.rb +87 -0
  168. data/lib/insika/model_visible_trace_store.rb +66 -0
  169. data/lib/insika/onboarding.rb +22 -1
  170. data/lib/insika/outbox_store.rb +42 -4
  171. data/lib/insika/outcome_store.rb +147 -0
  172. data/lib/insika/overlay_tool_registry.rb +37 -17
  173. data/lib/insika/packaging.rb +163 -0
  174. data/lib/insika/parity/criterion.rb +79 -0
  175. data/lib/insika/parity/verdict.rb +318 -0
  176. data/lib/insika/prefix_fingerprint.rb +58 -0
  177. data/lib/insika/profile_source.rb +29 -1
  178. data/lib/insika/prompt_catalog.rb +10 -0
  179. data/lib/insika/proposal_store.rb +271 -0
  180. data/lib/insika/queue_policy.rb +4 -1
  181. data/lib/insika/refinement/proposer.rb +1 -1
  182. data/lib/insika/reliability.rb +32 -6
  183. data/lib/insika/retention.rb +316 -0
  184. data/lib/insika/router/app.rb +157 -0
  185. data/lib/insika/router/backend_pool.rb +98 -0
  186. data/lib/insika/router/hash_ring.rb +55 -0
  187. data/lib/insika/router/proxy_body.rb +34 -0
  188. data/lib/insika/router/session_key.rb +54 -0
  189. data/lib/insika/router.rb +18 -0
  190. data/lib/insika/routing.rb +101 -0
  191. data/lib/insika/safety/config.rb +44 -4
  192. data/lib/insika/safety/corpus.rb +255 -0
  193. data/lib/insika/safety/detectors.rb +32 -113
  194. data/lib/insika/safety/factory.rb +15 -2
  195. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  196. data/lib/insika/safety/grounding_validator.rb +49 -0
  197. data/lib/insika/safety/input_guardrail.rb +1 -1
  198. data/lib/insika/safety/moderator.rb +1 -1
  199. data/lib/insika/safety/output_filter.rb +9 -5
  200. data/lib/insika/safety/output_validator.rb +11 -5
  201. data/lib/insika/schedule.rb +177 -0
  202. data/lib/insika/schedule_engine.rb +314 -0
  203. data/lib/insika/schedule_store.rb +208 -0
  204. data/lib/insika/schema_guard.rb +35 -0
  205. data/lib/insika/server/app.rb +247 -17
  206. data/lib/insika/server/rack_app.rb +21 -1
  207. data/lib/insika/server/responses.rb +40 -1
  208. data/lib/insika/session_actor.rb +8 -4
  209. data/lib/insika/session_store.rb +65 -2
  210. data/lib/insika/settings_store.rb +10 -0
  211. data/lib/insika/shadow_pair_store.rb +258 -0
  212. data/lib/insika/skill_catalog.rb +16 -0
  213. data/lib/insika/soak/envelope.rb +140 -0
  214. data/lib/insika/soak/report.rb +392 -0
  215. data/lib/insika/soak/runner.rb +554 -0
  216. data/lib/insika/steer_injector.rb +21 -10
  217. data/lib/insika/store.rb +10 -1
  218. data/lib/insika/stores/memory.rb +6 -0
  219. data/lib/insika/stores/sqlite.rb +8 -0
  220. data/lib/insika/studio/app.rb +1437 -54
  221. data/lib/insika/studio/assets/dist/application.css +1 -1
  222. data/lib/insika/studio/assets/dist/application.js +23 -23
  223. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  224. data/lib/insika/studio/forms.rb +308 -10
  225. data/lib/insika/studio/nav_icons.rb +19 -1
  226. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  227. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  228. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  229. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  230. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  231. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  232. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  233. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  234. data/lib/insika/studio/views/_agents_master.erb +44 -0
  235. data/lib/insika/studio/views/_message.erb +49 -32
  236. data/lib/insika/studio/views/agent_detail.erb +62 -278
  237. data/lib/insika/studio/views/agents.erb +71 -54
  238. data/lib/insika/studio/views/approvals.erb +4 -1
  239. data/lib/insika/studio/views/artifact.erb +23 -0
  240. data/lib/insika/studio/views/artifacts.erb +59 -0
  241. data/lib/insika/studio/views/chats.erb +4 -1
  242. data/lib/insika/studio/views/customer.erb +94 -0
  243. data/lib/insika/studio/views/customers.erb +32 -0
  244. data/lib/insika/studio/views/evals.erb +6 -3
  245. data/lib/insika/studio/views/facts.erb +133 -0
  246. data/lib/insika/studio/views/followups.erb +125 -0
  247. data/lib/insika/studio/views/funnel.erb +106 -0
  248. data/lib/insika/studio/views/harvest.erb +234 -0
  249. data/lib/insika/studio/views/home.erb +106 -66
  250. data/lib/insika/studio/views/knowledge.erb +123 -0
  251. data/lib/insika/studio/views/layout.erb +15 -11
  252. data/lib/insika/studio/views/mcp.erb +174 -80
  253. data/lib/insika/studio/views/parity.erb +147 -0
  254. data/lib/insika/studio/views/playground.erb +7 -1
  255. data/lib/insika/studio/views/session.erb +233 -124
  256. data/lib/insika/studio/views/settings.erb +41 -1
  257. data/lib/insika/studio/views/skills.erb +1 -2
  258. data/lib/insika/studio/views/system_files.erb +1 -1
  259. data/lib/insika/studio/views/task.erb +13 -0
  260. data/lib/insika/studio/views/tasks.erb +4 -1
  261. data/lib/insika/studio/views/tools.erb +24 -10
  262. data/lib/insika/task_store.rb +21 -1
  263. data/lib/insika/templates/browser-agent/README.md +36 -0
  264. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  265. data/lib/insika/templates/daily-digest/README.md +38 -0
  266. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  267. data/lib/insika/templates/repo-explorer/README.md +36 -0
  268. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  269. data/lib/insika/templates/research-analyst/README.md +26 -0
  270. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  271. data/lib/insika/templates/review-panel/README.md +20 -0
  272. data/lib/insika/templates/review-panel/agent.rb +50 -0
  273. data/lib/insika/templates/travel-planner/README.md +35 -0
  274. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  275. data/lib/insika/templates.rb +112 -0
  276. data/lib/insika/testing/store_contract.rb +27 -6
  277. data/lib/insika/tick.rb +47 -11
  278. data/lib/insika/timezone.rb +45 -0
  279. data/lib/insika/tool_definition.rb +17 -7
  280. data/lib/insika/tool_envelope.rb +69 -0
  281. data/lib/insika/tool_manifest.rb +5 -1
  282. data/lib/insika/tools/data_defined_tool.rb +10 -0
  283. data/lib/insika/tools/generate_image.rb +89 -0
  284. data/lib/insika/tools/load_knowledge.rb +74 -0
  285. data/lib/insika/tools/run_persona_eval.rb +328 -0
  286. data/lib/insika/tools/save_artifact.rb +95 -0
  287. data/lib/insika/tools/schedule_followup.rb +164 -0
  288. data/lib/insika/tools/tts.rb +47 -0
  289. data/lib/insika/tools/update_briefing.rb +126 -0
  290. data/lib/insika/turn_output.rb +1 -1
  291. data/lib/insika/turn_state.rb +49 -1
  292. data/lib/insika/turn_timing.rb +22 -2
  293. data/lib/insika/version.rb +1 -1
  294. data/lib/insika/vitals.rb +84 -0
  295. data/lib/insika/wiring/graph.rb +472 -13
  296. data/lib/insika/wiring/graph_chat.rb +102 -0
  297. data/lib/insika.rb +149 -3
  298. metadata +181 -5
  299. data/docs/build.md +0 -14
  300. data/docs/understand.md +0 -10
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Insika
6
+ # Template gallery: example agents shipped INSIDE the gem
7
+ # (`lib/insika/templates/<name>/{agent.rb,README.md}`), one DSL file per
8
+ # template that is BOTH doors — `insika new <name>` copies it for the user
9
+ # to run and edit, and this module `evaluate`s the same file to hand its
10
+ # pack(s) to the Studio's "New from template" gallery. No parallel pack
11
+ # format to drift.
12
+ #
13
+ # A template's `agent.rb` guards its CLI demo footer with
14
+ # `if __FILE__ == $PROGRAM_NAME` (false when this module evaluates it) and
15
+ # ends with the bare `Insika.agent`/`Insika.system` result as its LAST
16
+ # expression, so `evaluate` gets it back as the string-eval's return value
17
+ # — no registration call, no second source of truth.
18
+ module Templates
19
+ ROOT = File.expand_path("templates", __dir__)
20
+
21
+ Entry = Data.define(:name, :title, :trail, :description, :capabilities, :studio, :env, :requires) do
22
+ def studio? = studio
23
+ end
24
+
25
+ module_function
26
+
27
+ # -> [String] template dirs that have an agent.rb, lexicographic.
28
+ def names
29
+ return [] unless Dir.exist?(ROOT)
30
+
31
+ Dir.children(ROOT).select { |n| File.file?(agent_path(n)) }.sort
32
+ end
33
+
34
+ # -> [Entry] every template, parsed metadata only (no evaluation — cheap,
35
+ # safe to call on every render of the Studio gallery).
36
+ def all = names.map { |n| read(n) }
37
+
38
+ # -> Entry for one template. Raises NotFoundError for an unknown name —
39
+ # same discipline as a missing agent/MCP instance.
40
+ def read(name)
41
+ path = agent_path(name)
42
+ raise Insika::NotFoundError, "template '#{name}' not found" unless File.file?(path)
43
+
44
+ meta = frontmatter(File.read(path))
45
+ Entry.new(
46
+ name: name.to_s, title: presence(meta["title"]) || name.to_s, trail: presence(meta["trail"]),
47
+ description: meta["description"].to_s,
48
+ capabilities: split_list(meta["capabilities"]),
49
+ studio: meta.fetch("studio", true) != false,
50
+ env: split_list(meta["env"]), requires: presence(meta["requires"])
51
+ )
52
+ end
53
+
54
+ # Evaluates the template's agent.rb in an ISOLATED scope (a fresh Object's
55
+ # instance_eval) and returns whatever its last expression is — the built
56
+ # `Insika::DSL::Definition` or `Insika::DSL::System`. $PROGRAM_NAME here is
57
+ # whatever process called this (rspec, the CLI, the Studio server), never
58
+ # this file's path, so the template's own `if __FILE__ == $PROGRAM_NAME`
59
+ # demo footer never runs: no network call, no ARGV parsing, no puts.
60
+ #
61
+ # The fresh-Object receiver keeps a template's local variables and `def`s
62
+ # from leaking into the next one evaluated in the same process; a
63
+ # top-level CONSTANT would still leak (Ruby scopes constant assignment
64
+ # lexically, not by `self`) — wave-1 templates simply don't declare any
65
+ # (the conformance spec, would catch a future one that did).
66
+ def evaluate(name)
67
+ path = agent_path(name)
68
+ raise Insika::NotFoundError, "template '#{name}' not found" unless File.file?(path)
69
+
70
+ Object.new.instance_eval(File.read(path), path)
71
+ end
72
+
73
+ # -> [Pack] one per agent, regardless of whether the template is a single
74
+ # `Insika.agent` (Definition#to_pack) or a system (System#to_packs).
75
+ def packs_for(name)
76
+ built = evaluate(name)
77
+ built.respond_to?(:to_packs) ? built.to_packs : [built.to_pack]
78
+ end
79
+
80
+ def agent_path(name) = File.join(ROOT, name.to_s, "agent.rb")
81
+ def readme_path(name) = File.join(ROOT, name.to_s, "README.md")
82
+
83
+ # A `# ---` … `# ---` comment block at the very top of the file, YAML
84
+ # inside (each line stripped of its leading `# `). Not real Ruby
85
+ # frontmatter (there's no such thing) — a convention this module alone
86
+ # parses, so the metadata lives in the one file without needing a
87
+ # side-channel manifest.
88
+ def frontmatter(source)
89
+ lines = source.lines
90
+ # Every template starts with the same magic comment every other .rb
91
+ # file in the gem does — skip it (and any blank line) before looking
92
+ # for the block, so templates don't have to break that convention.
93
+ lines = lines.drop(1) while lines.first && (lines.first.strip.empty? || lines.first.strip == "# frozen_string_literal: true")
94
+ return {} unless lines.first&.strip == "# ---"
95
+
96
+ body = lines.drop(1)
97
+ .take_while { |l| l.strip != "# ---" }
98
+ .map { |l| l.sub(/\A#\s?/, "") }
99
+ .join
100
+ YAML.safe_load(body) || {}
101
+ end
102
+ private_class_method :frontmatter
103
+
104
+ def split_list(value)
105
+ value.to_s.split(",").map(&:strip).reject(&:empty?)
106
+ end
107
+ private_class_method :split_list
108
+
109
+ def presence(str) = Insika::Coercion.presence(str)
110
+ private_class_method :presence
111
+ end
112
+ end
@@ -69,18 +69,18 @@ RSpec.shared_examples "an Insika store" do
69
69
  end
70
70
 
71
71
  it " converts Symbols (keys and values) to Strings" do #
72
- store.set("s", "k", { chave: :valor })
73
- expect(store.get("s", "k")).to eq({ "chave" => "valor" })
72
+ store.set("s", "k", { key: :value })
73
+ expect(store.get("s", "k")).to eq({ "key" => "value" })
74
74
  end
75
75
 
76
76
  it " returns nil for an absent key, never an exception" do #
77
- expect(store.get("s", "nao-existe")).to be_nil
77
+ expect(store.get("s", "missing-key")).to be_nil
78
78
  end
79
79
 
80
80
  it " overwrites silently (last-write-wins)" do #
81
- store.set("s", "k", "primeiro")
82
- store.set("s", "k", "segundo")
83
- expect(store.get("s", "k")).to eq("segundo")
81
+ store.set("s", "k", "first")
82
+ store.set("s", "k", "second")
83
+ expect(store.get("s", "k")).to eq("second")
84
84
  end
85
85
 
86
86
  it " set returns the same object passed in (not the round-trip)" do #
@@ -128,6 +128,27 @@ RSpec.shared_examples "an Insika store" do
128
128
  end
129
129
  end
130
130
 
131
+ describe "#scopes" do
132
+ it " returns scope names sorted lexicographically" do #
133
+ store.set("zeta", "k", 1)
134
+ store.set("alpha", "k", 1)
135
+ store.set("alpha:child", "k", 1)
136
+ expect(store.scopes).to eq(["alpha", "alpha:child", "zeta"])
137
+ end
138
+
139
+ it " filters by prefix with start_with? (not include?)" do #
140
+ store.set("s", "k", 1)
141
+ store.set("s:child", "k", 1)
142
+ store.set("s2", "k", 1) # trap: contains "s" but does not start with "s:"
143
+ expect(store.scopes("s:")).to eq(["s:child"])
144
+ end
145
+
146
+ it " returns [] when nothing matches (or the store is empty)" do #
147
+ expect(store.scopes("nope")).to eq([])
148
+ expect(store.scopes).to eq([])
149
+ end
150
+ end
151
+
131
152
  describe "scope isolation" do
132
153
  it " keeps scopes independent in get/list/delete" do #
133
154
  store.set("s1", "k", 1)
data/lib/insika/tick.rb CHANGED
@@ -3,21 +3,23 @@
3
3
  require "time"
4
4
 
5
5
  module Insika
6
- # The periodic tick: durability stops waiting for a reboot. One
7
- # pass does two things, in this order:
6
+ # The periodic tick: durability stops waiting for a reboot. One
7
+ # pass does three things, in this order:
8
8
  #
9
9
  # 1. DRAIN the outbox (`ChannelDelivery#sweep`) — replies a previous pass
10
10
  # (or process) recorded and never claimed. Ungated: every record carries
11
11
  # its own transactional claim, so N workers draining is safe.
12
- # 2. SWEEP stale orphaned tasks (`Recovery#run(stale_after:)`) gated by a
13
- # bucketed claim (`Recovery.claim_sweep` on "tick:<epoch/interval>"), so
14
- # exactly one worker per window sweeps. The staleness threshold is the
15
- # liveness gate: a live :running turn is bounded by turn_timeout, so
16
- # anything untouched past it cannot be alive.
12
+ # 2. The engine's background duties, each gated by its OWN claim window so
13
+ # their O(n) scans never ride the 60 s loop: retention (daily), the
14
+ # outcome fold, the follow-up firer, and the recurring-schedule firer
15
+ # (the engine's own cron it superseded the "point your own cron at
16
+ # the route" decision, see docs/SCHEDULING.md).
17
+ # 3. SWEEP stale orphaned tasks (`Recovery#run(stale_after:)`) — gated by a
18
+ # bucketed claim, so exactly one worker per window sweeps. The staleness
19
+ # threshold is the liveness gate: a live :running turn is bounded by
20
+ # turn_timeout, so anything untouched past it cannot be alive.
17
21
  #
18
- # It is NOT a job queue: no schedules, no priorities, no fan-out. The
19
- # refinement hook once pictured here is dropped by merit —
20
- # docs/REFINEMENT.md's "no scheduler in the engine" stands.
22
+ # It is NOT a job queue: no schedules queue, no priorities, no fan-out.
21
23
  class Tick
22
24
  # 60s: a customer waiting on WhatsApp is the deadline. 900s = 3x the
23
25
  # default turn_timeout (300s) — the rule, not the number: the threshold
@@ -31,7 +33,8 @@ module Insika
31
33
 
32
34
  def initialize(store:, recovery:, channel_delivery:, logger: nil,
33
35
  interval: DEFAULT_INTERVAL, stale_after: DEFAULT_STALE_AFTER,
34
- sleeper: nil)
36
+ sleeper: nil, retention: nil, funnel: nil, followup: nil,
37
+ schedule: nil)
35
38
  @store = store
36
39
  @recovery = recovery
37
40
  @channel_delivery = channel_delivery
@@ -39,8 +42,25 @@ module Insika
39
42
  @interval = interval.to_i
40
43
  @stale_after = stale_after.to_i
41
44
  @sleeper = sleeper || method(:default_sleep)
45
+ @retention = retention # the daily age-based sweep; nil = none
46
+ @funnel = funnel # the tick-driven outcome fold; nil = none
47
+ @followup = followup # the tick-driven follow-up firer; nil = none
48
+ @schedule = schedule # the recurring-schedule firer; nil = none
42
49
  end
43
50
 
51
+ # the fold is wired after the Tick is built (the graph passes
52
+ # it to `executor.tick.funnel =` — the outcome/funnel stores come from the
53
+ # spine). Setter + kwarg: same shape as `retention`.
54
+ attr_accessor :funnel
55
+
56
+ # the follow-up firer, wired after the Tick is built (same
57
+ # shape as `funnel` — the stores come from the spine).
58
+ attr_accessor :followup
59
+
60
+ # the recurring-schedule firer, wired after the Tick is built
61
+ # (same shape — the stores come from the spine).
62
+ attr_accessor :schedule
63
+
44
64
  def enabled? = @interval.positive?
45
65
 
46
66
  # One pass, pure (no reactor needed): the serving loop calls it on a timer,
@@ -49,6 +69,22 @@ module Insika
49
69
  def run_once
50
70
  drained = @channel_delivery ? @channel_delivery.sweep : { dispatched: [] }
51
71
  summary = { dispatched: drained[:dispatched], resumed: [], failed: [] }
72
+ # WS8 retention: cheap when not claimed (its own daily window) — the
73
+ # O(n) scans never ride the 60 s loop.
74
+ retention_summary = @retention&.run
75
+ summary[:retention] = retention_summary if retention_summary
76
+ # the outcome fold — one pass per claim window, cheap
77
+ # when another worker holds it. Sits next to retention, on the same tick.
78
+ funnel_summary = @funnel&.run
79
+ summary[:funnel] = funnel_summary if funnel_summary
80
+ # the follow-up firer — the tick's third duty, gated by its
81
+ # OWN claim window so the O(n) scans never ride the 60 s loop.
82
+ followup_summary = @followup&.run
83
+ summary[:followup] = followup_summary if followup_summary
84
+ # the recurring-schedule firer — the tick's fourth duty,
85
+ # the same claim-window discipline as the follow-up firer.
86
+ schedule_summary = @schedule&.run
87
+ summary[:schedule] = schedule_summary if schedule_summary
52
88
  return summary unless claim_window
53
89
 
54
90
  result = @recovery.run(stale_after: @stale_after)
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # IANA timezone handling through the OS tz database — the
5
+ # engine's only route to a zone NAME (Ruby stdlib's `Time#getlocal` takes an
6
+ # offset, not a zone name). Shared by FollowupPolicy (quiet hours), the cron
7
+ # parser (next-fire materialization) and the doctor (zone existence).
8
+ #
9
+ # IANA names are resolved by pointing Ruby's `TZ` at the zone for the
10
+ # computation. Save/restore keeps the global intact; under the engine's
11
+ # cooperative fiber model — no IO between the save and the restore — the
12
+ # mutation is atomic on the calling fiber.
13
+ module Timezone
14
+ # The candidate tz-data roots (TZDIR first — Ruby's own lookup env). The
15
+ # zone name maps to a FILE under the root ("America/Sao_Paulo" ->
16
+ # "America/Sao_Paulo").
17
+ TZ_ROOTS = ([ENV["TZDIR"]] +
18
+ %w[/usr/share/zoneinfo /usr/share/lib/zoneinfo /etc/zoneinfo])
19
+ .compact.freeze
20
+
21
+ module_function
22
+
23
+ # -> bool: is `zone` an IANA name the OS tz database knows? A bogus zone
24
+ # is a malformed declaration — refused where the doctor can name it (an
25
+ # unknown ENV["TZ"] silently behaves as UTC, so existence is checked
26
+ # against the database, not by asking Time).
27
+ def known?(zone)
28
+ zone = zone.to_s
29
+ return true if zone == "UTC" || zone == "Etc/UTC"
30
+
31
+ TZ_ROOTS.any? { |root| File.directory?(root) && File.exist?(File.join(root, zone)) }
32
+ end
33
+
34
+ # Yields `time` interpreted in the given IANA zone (via a save/restore of
35
+ # ENV["TZ"] — the stdlib-only route to the OS tz database). Returns the
36
+ # block's value.
37
+ def in_zone(zone, time)
38
+ previous = ENV["TZ"]
39
+ ENV["TZ"] = zone.to_s
40
+ yield time
41
+ ensure
42
+ ENV["TZ"] = previous
43
+ end
44
+ end
45
+ end
@@ -34,7 +34,8 @@ module Insika
34
34
  # (masks/reconciles); the definition itself is agnostic to masking.
35
35
  ToolDefinition = Data.define(
36
36
  :name, :description, :parameters, :request, :response,
37
- :secret_headers, :side_effect, :timeout, :group, :tags, :halt_when
37
+ :secret_headers, :side_effect, :timeout, :group, :tags, :halt_when,
38
+ :evidence # Insika::Evidence::Spec | nil
38
39
  )
39
40
 
40
41
  class ToolDefinition
@@ -46,7 +47,7 @@ module Insika
46
47
  ARRAY_SUGAR = PARAM_TYPES.map { |t| "array:#{t}" }.freeze
47
48
  HTTP_METHODS = %w[GET HEAD POST PUT PATCH DELETE].freeze
48
49
  IDEMPOTENT = %w[GET HEAD].freeze # side_effect default = false
49
- EXTRACTS = %w[body_raw status json_path].freeze
50
+ EXTRACTS = %w[body_raw status json_path evidence_envelope].freeze
50
51
  NAME_RE = /\A[a-z][a-z0-9_]*\z/ # identifier for the model
51
52
  # A `.` in the placeholder enables the turn-context namespace `{{ctx.*}}`
52
53
  # separate from the model's `{{param}}`. Params follow NAME_RE (no
@@ -56,7 +57,7 @@ module Insika
56
57
  # resolved by DataDefinedTool. Closed allowlist (a typo becomes a validation
57
58
  # error, not a silently empty header).
58
59
  CTX_PREFIX = "ctx."
59
- CTX_FIELDS = %w[chat_id store_id agent_id tenant].freeze
60
+ CTX_FIELDS = %w[chat_id store_id agent_id tenant image_url].freeze
60
61
 
61
62
  # ---- safe subset of JSON Schema (R1) --------------------------------------
62
63
  # Types supported by EVERY provider (OpenAI/Anthropic/Gemini/DeepSeek/Bedrock).
@@ -74,7 +75,7 @@ module Insika
74
75
  # `parameters` accepts JSON Schema (Hash) OR the legacy flat array.
75
76
  def self.build(name:, description:, request:, parameters: nil, response: nil,
76
77
  secret_headers: nil, side_effect: nil, timeout: nil, group: nil, tags: nil,
77
- halt_when: nil)
78
+ halt_when: nil, evidence: nil)
78
79
  name = name.to_s
79
80
  raise Insika::ValidationError, "name must match #{NAME_RE.inspect}" unless NAME_RE.match?(name)
80
81
 
@@ -84,6 +85,10 @@ module Insika
84
85
  schema = normalize_params(parameters)
85
86
  req = normalize_request(request, top_level_names(schema))
86
87
  resp = normalize_response(response)
88
+ if resp[:extract] == "evidence_envelope" && evidence.nil?
89
+ raise Insika::ValidationError,
90
+ "extract 'evidence_envelope' requires an 'evidence' declaration"
91
+ end
87
92
 
88
93
  method = req[:method]
89
94
  effect = side_effect.nil? ? !IDEMPOTENT.include?(method) : (side_effect ? true : false)
@@ -93,7 +98,8 @@ module Insika
93
98
  secret_headers: Array(secret_headers).map(&:to_s), side_effect: effect,
94
99
  timeout: timeout.nil? ? nil : Integer(timeout),
95
100
  group: normalize_group(group), tags: normalize_tags(tags),
96
- halt_when: normalize_halt_when(halt_when)
101
+ halt_when: normalize_halt_when(halt_when),
102
+ evidence: Insika::Evidence::Spec.parse(evidence)
97
103
  )
98
104
  end
99
105
 
@@ -104,7 +110,7 @@ module Insika
104
110
  name: h[:name], description: h[:description], parameters: h[:parameters],
105
111
  request: h[:request] || {}, response: h[:response],
106
112
  secret_headers: h[:secret_headers], side_effect: h[:side_effect], timeout: h[:timeout],
107
- group: h[:group], tags: h[:tags], halt_when: h[:halt_when]
113
+ group: h[:group], tags: h[:tags], halt_when: h[:halt_when], evidence: h[:evidence]
108
114
  )
109
115
  end
110
116
 
@@ -405,7 +411,7 @@ module Insika
405
411
  # String-keyed Hash for persistence (ConfigStore stringifies again, but we
406
412
  # normalize here so the record is stable across backends).
407
413
  def to_h
408
- {
414
+ h = {
409
415
  "name" => name, "description" => description,
410
416
  "parameters" => parameters,
411
417
  "request" => request.transform_keys(&:to_s),
@@ -415,6 +421,10 @@ module Insika
415
421
  "group" => group, "tags" => tags,
416
422
  "halt_when" => halt_when&.transform_keys(&:to_s)
417
423
  }
424
+ # present only when declared — a tool without evidence is byte-identical
425
+ # to today (no declaration, no envelope processing).
426
+ h["evidence"] = evidence.to_h if evidence
427
+ h
418
428
  end
419
429
 
420
430
  # -> true when this response ENDS the turn (no further model call). `body` is the
@@ -56,6 +56,11 @@ module Insika
56
56
 
57
57
  started = monotonic
58
58
  result = with_gate { Async::Task.current.with_timeout(@timeout, ToolTimeout) { __getobj__.call(args) } }
59
+ # the ONE seam every tool result passes on its way to the model.
60
+ # For a declared-evidence tool: reshape to the lean envelope, record the ids
61
+ # on the ledger, hoard the attachments. No evidence = the result passes
62
+ # through untouched (one nil-check — parity).
63
+ result = process_evidence(result)
59
64
  record_side_effect!(call_id) if side_effect?
60
65
  trace(call_id, args, result, started)
61
66
  result
@@ -136,5 +141,69 @@ module Insika
136
141
  @checkpoint_store.record_side_effect(@state.task.id, turn: @state.turn,
137
142
  tool_call_id: call_id)
138
143
  end
144
+
145
+ # ---- evidence ---------------------------------------------
146
+
147
+ # The evidence spec for the wrapped tool (D4). Resolution order:
148
+ # 1. the wrapped tool responds to `evidence` -> its spec (the data-tool
149
+ # path — DataDefinedTool exposes its definition's evidence);
150
+ # 2. otherwise the tool_registry entry's metadata carries an `evidence`
151
+ # spec (the code-tool path — a registry tool opts in at registration).
152
+ # No spec = pass the result through untouched (parity, byte-identical).
153
+ def evidence_spec
154
+ tool = __getobj__
155
+ if tool.respond_to?(:evidence)
156
+ raw = tool.evidence
157
+ return raw && Insika::Evidence::Spec.parse(raw)
158
+ end
159
+
160
+ entry = @tool_registry.respond_to?(:entries) ? registry_entry(real_name) : nil
161
+ metadata = entry&.respond_to?(:metadata) ? entry.metadata : nil
162
+ raw = metadata && (metadata[:evidence] || metadata["evidence"])
163
+ raw && Insika::Evidence::Spec.parse(raw)
164
+ end
165
+
166
+ def registry_entry(name)
167
+ @tool_registry.entries.find { |e| e.name == name.to_s }
168
+ end
169
+
170
+ # -> result (possibly reshaped). NEVER raises out: a broken evidence result
171
+ # becomes the envelope error the model can act on, exactly like a malformed
172
+ # CALL is today. A tool ERROR result is never reshaped (an error must reach
173
+ # the model verbatim — the DataDefinedTool rule).
174
+ def process_evidence(result)
175
+ spec = evidence_spec
176
+ return result unless spec
177
+ return result if result.is_a?(Hash) && (result[:error] || result["error"])
178
+
179
+ raw = Insika::Evidence::Processor.raw(spec, result)
180
+ bad = Insika::SchemaGuard.violation_output(spec, raw)
181
+ return { error: bad } if bad
182
+
183
+ lean, attachments = Insika::Evidence::Processor.build(spec, raw)
184
+ record_evidence!(spec, lean)
185
+ hoard_attachments!(attachments)
186
+ lean
187
+ rescue StandardError => e
188
+ { error: "evidence processing failed: #{e.message}" }
189
+ end
190
+
191
+ # Ledger write + attachment hoarding, both via the state (duck-typed — the
192
+ # envelope's existing specs construct state stubs without these readers).
193
+ def record_evidence!(_spec, lean)
194
+ ledger = @state.respond_to?(:evidence_ledger) ? @state.evidence_ledger : nil
195
+ return unless ledger
196
+
197
+ ids = Array(lean["items"]).map { |i| i["id"] }
198
+ ledger.record(ids) unless ids.empty?
199
+ end
200
+
201
+ def hoard_attachments!(attachments)
202
+ return if attachments.empty?
203
+ return unless @state.respond_to?(:evidence_attachments)
204
+
205
+ @state.evidence_attachments ||= []
206
+ @state.evidence_attachments.concat(attachments)
207
+ end
139
208
  end
140
209
  end
@@ -77,7 +77,11 @@ module Insika
77
77
  "tags" => (Array(defaults["tags"]) | Array(t["tags"])), # inherited default; tags unioned
78
78
  # Per-tool only: "this result ends the turn" is a property of THIS backend's
79
79
  # response, never something a manifest default should hand to its siblings.
80
- "halt_when" => t["halt_when"]
80
+ "halt_when" => t["halt_when"],
81
+ # the evidence declaration (lean envelope + ledger feed are the
82
+ # SAME data contract — D1). Normalized here; a malformed declaration is a
83
+ # ValidationError at ingestion, isolable per tool (R4).
84
+ "evidence" => t["evidence"] && Insika::Evidence::Spec.parse(t["evidence"]).to_h
81
85
  }.compact
82
86
  end
83
87
 
@@ -43,6 +43,11 @@ module Insika
43
43
  def name = @definition.name
44
44
  def description = @definition.description
45
45
 
46
+ # the tool's own evidence declaration (a Spec | nil). The
47
+ # envelope's duck-typed resolution checks this FIRST — a data-tool declares
48
+ # its evidence on its definition, never in the registry metadata.
49
+ def evidence = @definition.evidence
50
+
46
51
  # FULL (nested) JSON Schema straight into RubyLLM's params_schema — it is what
47
52
  # the providers serialize (OpenAI/Anthropic/Gemini/Bedrock prefer
48
53
  # params_schema; parameters is just a fallback). Provider-agnostic and
@@ -148,6 +153,11 @@ module Insika
148
153
  when "status" then { status: result[:status] }
149
154
  when "body_raw" then http_ok?(result) ? result[:body] : http_error(result)
150
155
  when "json_path" then extract_json(result)
156
+ # the raw response body under an envelope-only key, so the
157
+ # ToolEnvelope can parse items/attachments. A non-2xx is an ERROR like
158
+ # any other extract — an error must reach the model verbatim.
159
+ when "evidence_envelope"
160
+ http_ok?(result) ? { "__insika_body" => result[:body].to_s } : http_error(result)
151
161
  end
152
162
  end
153
163
 
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Insika
6
+ module Tools
7
+ # The agent's IMAGE output (WS9, saída). The engine transports media, never
8
+ # meaning: what the image IS for (a virtual try-on, a product mockup) is the
9
+ # skill's business — the contract here just produces the bytes and carries
10
+ # them in the turn's `output_parts`.
11
+ #
12
+ # Wired ONLY when both gates pass (ChatBuilder): the agent opted in
13
+ # (`outputs.image`) AND the channel declared it can receive the media
14
+ # (`channel.capabilities` includes "image_output") — nothing leaks by
15
+ # default. The image is an envelope part, never part of the answer text;
16
+ # the provider's tokens are merged into the turn's usage like any ask.
17
+ #
18
+ # Also EDITS — `source_image_urls` (or, absent that, the turn's
19
+ # own inbound photo) rides `paint(with:)`; `mask_url` rides `paint(mask:)`.
20
+ # What the edit MEANS (a try-on, a mockup) is the skill's business; this
21
+ # tool only transports the bytes.
22
+ class GenerateImage < RubyLLM::Tool
23
+ description "Generate an image, or EDIT one, and attach it to the reply as an " \
24
+ "output part. Use when the customer asked for a picture, or asked to " \
25
+ "transform/edit a photo (a virtual try-on, a mockup on their wall, a " \
26
+ "touch-up). Omitting source_image_urls generates a new image from the " \
27
+ "prompt alone — UNLESS this turn carries an inbound photo, in which case " \
28
+ "that photo is edited by default (pass source_image_urls explicitly to " \
29
+ "generate from scratch instead)."
30
+ # explicit JSON-schema form (the `param` DSL only reaches strings/scalars,
31
+ # and source_image_urls needs a typed array — the bare-array gotcha, #128).
32
+ params(
33
+ type: "object",
34
+ properties: {
35
+ prompt: { type: "string", description: "What to draw, or what edit to make, in detail" },
36
+ size: { type: "string",
37
+ description: "Optional canvas size, e.g. 1024x1024 (default from the agent config)" },
38
+ source_image_urls: {
39
+ type: "array",
40
+ description: "Image URLs to edit instead of generating from scratch — e.g. " \
41
+ "the photo the customer just sent in this conversation " \
42
+ "({{ctx.image_url}}), or any other URL from this chat. Omit to use " \
43
+ "the turn's inbound photo by default (if any), or to generate a " \
44
+ "fresh image when there is none.",
45
+ items: { type: "string" }
46
+ },
47
+ mask_url: { type: "string",
48
+ description: "Optional mask image URL marking which area of the " \
49
+ "source(s) to edit (transparent = editable)" }
50
+ },
51
+ required: %w[prompt]
52
+ )
53
+
54
+ def name = "generate_image"
55
+
56
+ # runner: a duck exposing #generate_media_output(:image, prompt, config)
57
+ # -> [part, usage] and #account_media_usage(part, usage) (the Executor).
58
+ def initialize(runner:, config:, state:, **)
59
+ @runner = runner
60
+ @config = config
61
+ @state = state
62
+ super()
63
+ end
64
+
65
+ def execute(prompt:, size: nil, source_image_urls: nil, mask_url: nil)
66
+ cfg = @config.merge("size" => size.to_s, "mask_url" => mask_url.to_s)
67
+ .reject { |_, v| v.to_s.empty? }
68
+ cfg = cfg.merge(source_config(source_image_urls))
69
+ part, usage = @runner.generate_media_output(:image, prompt.to_s, cfg)
70
+ @state.output_parts << part
71
+ @runner.account_media_usage(@state, part, usage)
72
+ "image generated and attached to the reply (#{part["mime_type"]})"
73
+ end
74
+
75
+ private
76
+
77
+ # Explicit URLs win over the default; a turn with inbound images (no
78
+ # explicit URLs) hands `Output.generate_image` the ALREADY-FETCHED
79
+ # attachments (bypassing the URL fetch — they are bytes we hold).
80
+ def source_config(source_image_urls)
81
+ urls = Array(source_image_urls).map(&:to_s).reject(&:empty?)
82
+ return { "source_urls" => urls } if urls.any?
83
+ return {} unless Array(@state.image_attachments).any?
84
+
85
+ { "source_attachments" => @state.image_attachments }
86
+ end
87
+ end
88
+ end
89
+ end