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,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ module Providers
6
+ # Read path for the session briefing: the per-session
7
+ # working-state the agent keeps and asks for. Thin adapter over the
8
+ # SessionStore, same pattern as Memory: one `:system` fragment,
9
+ # deterministic. The MISSING list is rendered, never implied — that list
10
+ # is what stops the model re-asking for a field already given.
11
+ class Briefing < ContextProvider
12
+ def initialize(session_store:)
13
+ @session_store = session_store
14
+ end
15
+
16
+ # Stable id -> the context-trace category "briefing".
17
+ def id = "briefing"
18
+
19
+ # Pack-declared: no briefing_fields -> no provider (the Builder still
20
+ # applies the `context_providers` allowlist on top — two gates, like Memory).
21
+ def enabled_for?(profile)
22
+ fields = profile.respond_to?(:briefing_fields) ? profile.briefing_fields : nil
23
+ !Array(fields).empty?
24
+ end
25
+
26
+ # required? == false (default): a store failure degrades via the Builder's
27
+ # warning path, never aborts the turn.
28
+ def call(request)
29
+ session = request.respond_to?(:session) ? request.session : nil
30
+ return [] if session.nil? # one-shot turns have no briefing
31
+
32
+ briefing = briefing_for(session)
33
+ fields = briefing["fields"] || {}
34
+ declared = Array(request.profile.briefing_fields)
35
+ return [] if declared.empty? # defensive; enabled_for? already gates
36
+
37
+ block = format_block(declared, fields, briefing["next_step"])
38
+ return [] if block.nil?
39
+
40
+ [ContextFragment.build(content: block, placement: :system,
41
+ priority: Context::Priority::BRIEFING,
42
+ source: id)]
43
+ end
44
+
45
+ private
46
+
47
+ # Re-reads the briefing from the store, like the Session provider: the
48
+ # persisted record is the source of truth, not the request's turn-start
49
+ # snapshot. A read failure propagates to the Builder, which degrades it
50
+ # to a :provider_warning (required? == false).
51
+ def briefing_for(session)
52
+ @session_store.find(session.id)&.briefing || {}
53
+ end
54
+
55
+ # Byte contract (the specs assert this shape):
56
+ # <briefing>
57
+ # known:
58
+ # size: M
59
+ # still missing: delivery_day
60
+ # next step: send the payment link tomorrow at 10
61
+ # </briefing>
62
+ # Rules: `known` renders only when at least one declared field has a
63
+ # stored value; `still missing` renders every declared field with no
64
+ # stored value (including the all-missing case — that is the block's
65
+ # job); `next step` renders only when non-nil; stored keys NOT in the
66
+ # declaration are never rendered (they stay in the store and reappear if
67
+ # the pack re-declares them).
68
+ def format_block(declared, fields, next_step)
69
+ known = declared.filter_map do |name|
70
+ " #{name}: #{flatten(fields[name])}" if Coercion.present?(fields[name])
71
+ end
72
+ missing = declared.reject { |name| Coercion.present?(fields[name]) }
73
+
74
+ lines = []
75
+ lines << "known:" unless known.empty?
76
+ lines.concat(known)
77
+ lines << "still missing: #{missing.join(', ')}" unless missing.empty?
78
+ lines << "next step: #{flatten(next_step)}" if Coercion.present?(next_step)
79
+ return nil if lines.empty?
80
+
81
+ <<~BLOCK.strip
82
+ <briefing>
83
+ #{lines.join("\n")}
84
+ </briefing>
85
+ BLOCK
86
+ end
87
+
88
+ # utf8 the value and flatten newlines/whitespace so a value can never
89
+ # break the block's line structure.
90
+ def flatten(value)
91
+ Coercion.utf8(value.to_s).gsub(/\s+/, " ").strip
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ module Providers
6
+ # Level 1 (progressive disclosure) of what the engine has LEARNED, as
7
+ # opposed to what a human curated (Skill) or the model wrote mid-turn
8
+ # (Memory). Retrieval is per-message and dynamic, so — like
9
+ # `SkillTrigger`, unlike the static `CatalogProvider` subclasses — this
10
+ # builds its `<knowledge>` block directly in `call`, never a fixed list.
11
+ class Knowledge < ContextProvider
12
+ def initialize(store:)
13
+ @store = store
14
+ # One Index PER TYPE, built once and reused for every agent/turn —
15
+ # never per call. This provider instance itself lives for the
16
+ # process's lifetime (built once at boot, see wiring), so an
17
+ # Index rebuilt fresh each call would throw away its own read
18
+ # cache (Index::Scan's dominant cost is re-parsing YAML
19
+ # frontmatter; measured, not assumed) on every single turn.
20
+ # Keyed by the config's `index` string so a future FTS5 agent
21
+ # gets its own instance, never Scan's.
22
+ @indexes = Hash.new { |h, index_name| h[index_name] = Insika::Knowledge::Index.build({ "index" => index_name }, store: @store) }
23
+ end
24
+
25
+ # Per-agent opt-in (`knowledge.retrieve`), like Memory's `profile.memory`.
26
+ def enabled_for?(profile) = !!(profile.knowledge && Coercion.truthy?(profile.knowledge["retrieve"]))
27
+
28
+ # required? == false (default): a store failure degrades to a
29
+ # :provider_warning, never aborts the turn.
30
+ def call(request)
31
+ config = request.profile.knowledge
32
+ return [] unless config
33
+
34
+ top_k = positive_int(config["top_k"]) || 5
35
+ index = @indexes[config["index"].to_s]
36
+ matches = index.search(request.profile.id, tenant: request.tenant,
37
+ query: request.message.to_s, top_k: top_k)
38
+ return [] if matches.empty?
39
+
40
+ hits = matches.map { |c| [c, "top-K match"] } +
41
+ expand_links(matches, request, top_k).map { |c| [c, "one-hop link"] }
42
+
43
+ [ContextFragment.build(
44
+ content: format_block(hits), placement: :system,
45
+ priority: Context::Priority::KNOWLEDGE, source: id,
46
+ labels: hits.map { |c, reason| { "name" => c[:name], "reason" => reason } }
47
+ )]
48
+ end
49
+
50
+ private
51
+
52
+ def positive_int(value)
53
+ n = value.to_i
54
+ n.positive? ? n : nil
55
+ end
56
+
57
+ # ONE level, deliberately — the same "cannot work without" reasoning
58
+ # `SkillTrigger#companions` applies to a skill's declared companions:
59
+ # a transitive walk would make a cycle a hang and a chain a budget
60
+ # blowout. Newly-discovered concepts (not already in the top-K) are
61
+ # capped at top_k again — "that one hop is the whole graph benefit at
62
+ # ~1% of the graph cost", not a second unbounded retrieval.
63
+ def expand_links(matches, request, top_k)
64
+ known = matches.map { |c| c[:name] }
65
+ discovered = []
66
+ matches.each do |concept|
67
+ Insika::Knowledge::Concept.links(concept[:body]).each do |name|
68
+ next if known.include?(name) || discovered.any? { |d| d[:name] == name }
69
+
70
+ found = fetch(request, name)
71
+ discovered << found if found
72
+ end
73
+ end
74
+ discovered.first(top_k)
75
+ end
76
+
77
+ def fetch(request, name)
78
+ raw = @store.get(request.profile.id, name, tenant: request.tenant)
79
+ raw && Insika::Knowledge::Concept.parse(raw)
80
+ end
81
+
82
+ # Level 1 only — name/description/confidence/provenance, never the
83
+ # body (that is `load_knowledge`'s job). The instruction is the exact
84
+ # lesson the knowledge-adoption experiment drew: a polite "when to
85
+ # use" scored near zero; an explicit, ordered rule naming the tool
86
+ # held up. Present only when there is something to point at.
87
+ def format_block(hits)
88
+ entries = hits.map do |c, _reason|
89
+ %( <concept name="#{c[:name]}" confidence="#{format('%.2f', c[:confidence])}" ) +
90
+ %(provenance="#{c[:provenance]}">#{c[:description]}</concept>)
91
+ end.join("\n")
92
+
93
+ <<~BLOCK.strip
94
+ <knowledge>
95
+ #{entries}
96
+ </knowledge>
97
+
98
+ If the customer's question needs more than the summary above, call
99
+ `load_knowledge("name")` FIRST — before any other lookup for that
100
+ topic. This is learned from past conversations, not official
101
+ policy: never state a `provenance="observed"` concept to the
102
+ customer as a guarantee.
103
+ BLOCK
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -20,7 +20,7 @@ module Insika
20
20
  # required? == false (default): a failure (store unavailable) becomes a
21
21
  # :provider_warning + graceful degradation — never aborts the turn.
22
22
  def call(request)
23
- tenant = memory_tenant(request)
23
+ tenant = memory_scope(request)
24
24
  facts = @store.facts(tenant: tenant)
25
25
  notes = @store.notes(tenant: tenant, limit: @notes_limit)
26
26
  return [] if facts.empty? && notes.empty?
@@ -33,15 +33,24 @@ module Insika
33
33
 
34
34
  private
35
35
 
36
- # Engine memory scope: an EXPLICIT tenant from the Command wins
37
- # (multi-merchant override); otherwise the SESSION (=chat) — engine-owner
38
- # memory is per-chat. No session (one-shot) and no tenant -> nil (MemoryStore
39
- # applies _default). Symmetric to the write path (`state.tenant` in the Executor).
40
- def memory_tenant(request)
36
+ # Engine memory scope (WS8): the request's CUSTOMER-scoped cell
37
+ # ("[tenant:]customer" — engine-owner memory is per customer, never per
38
+ # tenant) wins; otherwise an EXPLICIT tenant from the Command (the
39
+ # multi-merchant override); otherwise the SESSION (=chat), MARKED like
40
+ # the write path ("chat:<session id>" — : a session cell is
41
+ # never a bare cell, so the drill cannot read a conversation as a
42
+ # customer). No session (one-shot) and no tenant -> nil (MemoryStore
43
+ # applies _default). Symmetric to the write path (`state.tenant` in the
44
+ # Executor).
45
+ def memory_scope(request)
46
+ scoped = request.respond_to?(:memory_scope) ? request.memory_scope : nil
47
+ return scoped if scoped
48
+
41
49
  explicit = request.respond_to?(:tenant) ? request.tenant : nil
42
50
  return explicit if explicit
43
51
 
44
- request.respond_to?(:session) ? request.session&.id : nil
52
+ session = request.respond_to?(:session) ? request.session : nil
53
+ session && session.id ? "#{Insika::MemoryStore::SESSION_TAG}:#{session.id}" : nil
45
54
  end
46
55
 
47
56
  # Passive <memory> (no instruction — the HOW of writing lives in the `remember` tool).
@@ -8,30 +8,58 @@ module Insika
8
8
  # a WRONG agent, not a degraded one. prompt_refs: priority 90 pinned
9
9
  # fragments, from the PromptCatalog (catalog defaults to nil).
10
10
  #
11
- # PER-AGENT identity. `profile.prompt_files` (file names)
12
- # wins over the wiring's `files:` — this fixes the limitation of a new
13
- # agent inheriting Bia's prompt. The content comes from `agent_files`
14
- # (AgentFileStore, lives in the Store), with a File.read fallback
15
- # for on-disk paths (compat/seed). Without prompt_files -> uses the wiring's
16
- # `files:` (deployment default; byte-for-byte parity).
11
+ # PER-AGENT identity, from `profile.prompt_files`/`base_prompt` only — an
12
+ # agent that declares neither has no identity here, and there is no
13
+ # deployment-wide fallback file to borrow one from. A shared fallback
14
+ # (a prior design: an agent without prompt_files silently inherited a
15
+ # wiring-level default) is exactly how a real deployment answered as the
16
+ # WRONG business: a `copilot` data agent provisioned without its own
17
+ # identity inherited the deployment's demo persona ("Bia, Pizzaria do
18
+ # Zé") byte for byte, confirmed live. An agent never wears another
19
+ # agent's identity — `@base`/`system_files` stay legitimate (the same
20
+ # generic content for every agent, never one agent's specific persona).
17
21
  class Prompt < ContextProvider
18
- def initialize(base: "", files: [], catalog: nil, agent_files: nil, system_files: nil)
22
+ # Engine-owned execution discipline, appended AFTER the agent's identity.
23
+ # The one behavior every reference harness bakes into its base prompt
24
+ # (OpenClaw's "Execution Bias") and this engine was missing: a weak tool
25
+ # result read as final. A constant — byte-identical every turn, so
26
+ # prompt_caching pays ONE write on the deploy that introduces it, never
27
+ # per turn. Opt-out per profile (`tool_persistence false`), the single
28
+ # default-ON profile flag: the proven-good behavior is the default, the
29
+ # exception is the thing an operator declares.
30
+ TOOL_PERSISTENCE = "## Tool discipline\n" \
31
+ "- Weak or empty tool result: try again with a different approach — rephrase the " \
32
+ "query, use a synonym or broader term, drop a secondary filter — before telling " \
33
+ "the user you found nothing. Do not narrate the retries. Then conclude.\n" \
34
+ "- Tool error: read the error, fix the arguments or try another path; never " \
35
+ "repeat the exact same call.\n" \
36
+ "- A URL in a tool result (e.g. a `url` field): quote it byte-for-byte. Never " \
37
+ "construct, guess, or rewrite the domain, host, or path."
38
+
39
+ def initialize(base: "", catalog: nil, agent_files: nil, system_files: nil)
19
40
  @base = base
20
- @files = Array(files)
21
41
  @catalog = catalog
22
42
  @agent_files = agent_files
23
43
  @system_files = system_files
24
44
  end
25
45
 
26
46
  def required? = true
47
+ # identity (config/agent-file derived — already pinned).
48
+ def layer = :identity
27
49
 
28
50
  def call(request)
29
- fragments = []
30
51
  identity = build_identity(request.profile)
31
- unless identity.empty?
32
- fragments << ContextFragment.build(content: identity, placement: :system,
33
- priority: Context::Priority::IDENTITY, source: id, pinned: true)
52
+ if identity.empty?
53
+ raise ContextError.new(
54
+ "agent '#{request.profile&.id}' has no identity of its own (no base_prompt, " \
55
+ "no prompt_files) — refusing to run rather than answer with no identity or " \
56
+ "another agent's",
57
+ provider: id
58
+ )
34
59
  end
60
+
61
+ fragments = [ContextFragment.build(content: identity, placement: :system,
62
+ priority: Context::Priority::IDENTITY, source: id, pinned: true)]
35
63
  fragments.concat(ref_fragments(request.profile))
36
64
  fragments
37
65
  end
@@ -42,10 +70,9 @@ module Insika
42
70
  # A SINGLE fragment preserves the internal base->files order (sorting
43
71
  # acts only BETWEEN fragments) and guarantees byte-for-byte parity.
44
72
  #
45
- # profile.prompt_files (names) wins over @files (wiring): an agent with
46
- # its own identity does not inherit the deployment's. Each source resolves
47
- # via AgentFileStore (per agent) OR File.read (on-disk path) — in that
48
- # order. Without prompt_files, falls back to the wiring's @files.
73
+ # profile.prompt_files: each source resolves via AgentFileStore (per
74
+ # agent) OR File.read (on-disk path) — in that order. No other agent's
75
+ # files are ever read for this one; there is no wiring-level fallback.
49
76
  def build_identity(profile)
50
77
  parts = [@base]
51
78
  parts.concat(system_parts) # global system files, for EVERY agent
@@ -56,13 +83,20 @@ module Insika
56
83
  # NO identity at all, and a chatty model answered plausibly enough to hide
57
84
  # it. Additive to prompt_files, not exclusive: an agent may carry both.
58
85
  parts << profile&.base_prompt.to_s
59
- sources = Array(profile&.prompt_files)
60
- if sources.empty?
61
- @files.each { |f| parts << File.read(f, encoding: "UTF-8") if File.exist?(f) }
62
- else
63
- sources.each { |src| parts << read_source(profile&.id, src.to_s) }
64
- end
65
- parts.reject { |p| p.nil? || p.strip.empty? }.join("\n\n")
86
+ Array(profile&.prompt_files).each { |src| parts << read_source(profile&.id, src.to_s) }
87
+ identity = parts.reject { |p| p.nil? || p.strip.empty? }.join("\n\n")
88
+ # Discipline rides an EXISTING identity, never substitutes one: an
89
+ # empty identity here is fatal (`call` raises), not silently patched
90
+ # with the engine's own boilerplate.
91
+ return identity if identity.empty? || !tool_persistence?(profile)
92
+
93
+ "#{identity}\n\n#{TOOL_PERSISTENCE}"
94
+ end
95
+
96
+ # nil/absent/true = ON (the engine default); only an explicit `false`
97
+ # turns it off. Defensive respond_to?: a minimal profile stub reads ON.
98
+ def tool_persistence?(profile)
99
+ !(profile.respond_to?(:tool_persistence) && profile.tool_persistence == false)
66
100
  end
67
101
 
68
102
  # GLOBAL system files: apply to every agent,
@@ -10,6 +10,8 @@ module Insika
10
10
  class Skill < CatalogProvider
11
11
  # priority 80: above deferred tools (70), below pinned identity.
12
12
  def priority = Context::Priority::SKILL
13
+ # catalog + allowlist — config only.
14
+ def layer = :identity
13
15
 
14
16
  private
15
17
 
@@ -10,6 +10,8 @@ module Insika
10
10
  class ToolSearch < CatalogProvider
11
11
  # priority 70: below skills (80) in the sacrifice order.
12
12
  def priority = Context::Priority::TOOL_SEARCH
13
+ # tool registry + tools_deferred — config only.
14
+ def layer = :identity
13
15
 
14
16
  private
15
17
 
@@ -24,7 +24,8 @@ module Insika
24
24
  # suspends (approval) and resumes re-runs the context stage, and the
25
25
  # re-record replaces the first one instead of duplicating it. (`turn` is
26
26
  # 1-based PER TASK, so the task is part of the key.) Missing session_id ->
27
- # no-op.
27
+ # no-op. -> the sanitized entry (parks it on TurnState for the
28
+ # stage-8 cache merge).
28
29
  def record(session_id:, entry:)
29
30
  sid = session_id.to_s
30
31
  return if sid.empty?
@@ -33,6 +34,7 @@ module Insika
33
34
  key = [e["task_id"], e["turn"]]
34
35
  list = (@store.get(SCOPE, sid) || []).reject { |x| [x["task_id"], x["turn"]] == key } + [e]
35
36
  @store.set(SCOPE, sid, list.last(MAX_PER_SESSION))
37
+ e
36
38
  rescue StandardError
37
39
  nil
38
40
  end
@@ -63,6 +65,11 @@ module Insika
63
65
  cat = { "tokens" => int(c[:tokens] || c["tokens"]),
64
66
  "fragments" => int(c[:fragments] || c["fragments"]),
65
67
  "pinned" => int(c[:pinned] || c["pinned"]) }
68
+ # which cache layer the category belongs to ("identity" |
69
+ # "volatile"). Absent for a category recorded before the contract (or
70
+ # one that never learned it) — the view guards on nil.
71
+ layer = c[:layer] || c["layer"]
72
+ cat["layer"] = layer.to_s if layer
66
73
  # WHAT the category carried and WHY ({name, reason}) — still ids only, so
67
74
  # the no-masking-needed contract above holds. Omitted when empty: most
68
75
  # categories have nothing to name and an empty key is just noise.
@@ -71,8 +78,36 @@ module Insika
71
78
  acc[name.to_s] = cat
72
79
  end,
73
80
  "tools" => { "count" => int(tools[:count] || tools["count"]),
74
- "tokens" => int(tools[:tokens] || tools["tokens"]) }
81
+ "tokens" => int(tools[:tokens] || tools["tokens"]) },
82
+ "fingerprints" => fingerprints_of(e[:fingerprints] || e["fingerprints"]),
83
+ "cache" => cache_of(e[:cache] || e["cache"])
84
+ }.compact
85
+ end
86
+
87
+ # { name => sha256-hex }; names stringified, non-strings
88
+ # dropped. Absent when the caller passed nothing (a trace recorded before
89
+ # this feature has no key and the view guards on nil).
90
+ def fingerprints_of(raw)
91
+ return nil unless raw.is_a?(Hash) && !raw.empty?
92
+
93
+ raw.each_with_object({}) do |(name, hex), acc|
94
+ acc[name.to_s] = hex.to_s if hex.is_a?(String)
95
+ end.then { |h| h.empty? ? nil : h }
96
+ end
97
+
98
+ # { hit_pct, cached_tokens, prompt_tokens, invalidation_reason }.
99
+ # Unknown keys dropped. Present only when the caller passed it.
100
+ def cache_of(raw)
101
+ return nil unless raw.is_a?(Hash)
102
+
103
+ c = {
104
+ "hit_pct" => int_or_nil(raw[:hit_pct] || raw["hit_pct"]),
105
+ "cached_tokens" => int(raw[:cached_tokens] || raw["cached_tokens"]),
106
+ "prompt_tokens" => int(raw[:prompt_tokens] || raw["prompt_tokens"]),
107
+ "invalidation_reason" => raw[:invalidation_reason] || raw["invalidation_reason"]
75
108
  }
109
+ c["invalidation_reason"] = c["invalidation_reason"].to_s unless c["invalidation_reason"].nil?
110
+ c
76
111
  end
77
112
 
78
113
  # Labels are {name, reason} in string keys (ContextFragment.label). A bare string
@@ -88,5 +123,6 @@ module Insika
88
123
  end
89
124
 
90
125
  def int(value) = Integer(value || 0)
126
+ def int_or_nil(value) = value.nil? ? nil : Integer(value)
91
127
  end
92
128
  end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # A cron expression, parsed. The engine's documented subset:
5
+ #
6
+ # minute hour day-of-month month day-of-week (5 fields, whitespace-separated)
7
+ #
8
+ # Per field: `*`, a single value, a range (N-M), a step (`*/N`, `N-M/N`,
9
+ # `N/N`), or a comma list of those. `?` and `*` both mean "any". `L`, `W`,
10
+ # `#` and month/day NAMES are NOT — the engine refuses them loudly at
11
+ # creation, never silently dropping a cron that only some dates understand.
12
+ #
13
+ # Day-of-week: 0-7, 7 == 0 (Sunday). When BOTH day fields are restricted, a
14
+ # date matches on EITHER (standard cron OR semantics).
15
+ #
16
+ # `next_after(time)` returns the first occurrence STRICTLY after `time`,
17
+ # materialized in the schedule's tz (returned as a UTC Time), or nil when
18
+ # the expression can never fire within 400 years (`0 0 31 2 *`). The bound
19
+ # keeps a syntactic-but-unreachable expression from hanging a pass.
20
+ class Cron
21
+ MONTHS_31 = [1, 3, 5, 7, 8, 10, 12].freeze
22
+
23
+ # One field: the sorted accepted values + whether it was a bare "*"
24
+ # (the day-of-week OR rule needs to know which fields are restricted).
25
+ class Field
26
+ def initialize(expression, range, field:)
27
+ @field = field
28
+ @range = range
29
+ @star = expression == "*"
30
+ @values = expression.split(",").flat_map { |term| parse_term(term) }.uniq.sort
31
+ end
32
+
33
+ def star? = @star
34
+
35
+ def matches?(value) = @values.include?(value)
36
+
37
+ # -> Integer | nil: the smallest accepted value >= start (nil when none).
38
+ def at_or_after(start) = @values.find { |v| v >= start }
39
+
40
+ private
41
+
42
+ def parse_term(term)
43
+ head, step = term.include?("/") ? term.split("/", 2) : [term, nil]
44
+ step = parse_step(step) if step
45
+ base = parse_base(head)
46
+ base = base.each_with_index.filter_map { |v, i| v if (i % step).zero? } if step
47
+ base.each { |v| validate(v) }
48
+ end
49
+
50
+ def parse_step(raw)
51
+ n = Integer(raw)
52
+ raise Insika::ValidationError, "cron #{@field} step must be a positive integer, got #{raw.inspect}" unless n.positive?
53
+
54
+ n
55
+ rescue ArgumentError
56
+ raise Insika::ValidationError,
57
+ "cron #{@field} step must be a positive integer, got #{raw.inspect}"
58
+ end
59
+
60
+ def parse_base(head)
61
+ if head.empty? || head == "?" || head == "*"
62
+ @range.to_a
63
+ elsif head.include?("-")
64
+ lo, hi = head.split("-", 2).map { |s| value(s) }
65
+ raise Insika::ValidationError, "cron #{@field} range reversed: #{head.inspect}" if lo > hi
66
+
67
+ (lo..hi).to_a
68
+ else
69
+ [value(head)]
70
+ end
71
+ end
72
+
73
+ def value(raw)
74
+ v = Integer(raw)
75
+ v = 0 if @field == :dow && v == 7 # Sunday, the standard second spelling
76
+ v
77
+ rescue ArgumentError
78
+ raise Insika::ValidationError,
79
+ "cron #{@field} has an unparseable value: #{raw.inspect}"
80
+ end
81
+
82
+ def validate(v)
83
+ return if @range.cover?(v)
84
+
85
+ raise Insika::ValidationError,
86
+ "cron #{@field} value #{v} out of range (#{@range.inspect})"
87
+ end
88
+ end
89
+
90
+ attr_reader :expression
91
+
92
+ def initialize(expression)
93
+ @expression = expression.to_s.strip
94
+ fields = @expression.split(/\s+/)
95
+ if fields.size != 5
96
+ raise Insika::ValidationError,
97
+ "cron expression must have 5 fields (minute hour dom month dow), " \
98
+ "got #{fields.size}: #{@expression.inspect}"
99
+ end
100
+
101
+ minute, hour, dom, month, dow = fields
102
+ @minute = Field.new(minute, 0..59, field: :minute)
103
+ @hour = Field.new(hour, 0..23, field: :hour)
104
+ @dom = Field.new(dom, 1..31, field: :dom)
105
+ @month = Field.new(month, 1..12, field: :month)
106
+ @dow = Field.new(dow, 0..6, field: :dow)
107
+ freeze
108
+ end
109
+
110
+ # -> Time (UTC) | nil: the first instant strictly after `time` whose
111
+ # wall-clock in `tz` matches. nil = the expression cannot fire within 400y.
112
+ def next_after(time, tz: "Etc/UTC")
113
+ Timezone.in_zone(tz, time) do
114
+ local = time.getlocal
115
+ cur = Cursor.new(local.year, local.month, local.day, local.hour, local.min)
116
+ advance_minute!(cur) # strictly after `time`
117
+ limit = cur.y + 400
118
+ while cur.y <= limit
119
+ if @month.matches?(cur.mo) && day_match?(cur)
120
+ if @hour.matches?(cur.h) && (mm = @minute.at_or_after(cur.mi))
121
+ return Time.local(cur.y, cur.mo, cur.d, cur.h, mm)
122
+ end
123
+
124
+ nh = @hour.at_or_after(cur.h + 1)
125
+ if nh && (mm = @minute.at_or_after(0))
126
+ return Time.local(cur.y, cur.mo, cur.d, nh, mm)
127
+ end
128
+ end
129
+ advance_day!(cur)
130
+ cur.h = 0
131
+ cur.mi = 0
132
+ end
133
+ nil
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ # A date matches when a restricted day-of-month OR day-of-week accepts it
140
+ # (standard cron); a bare "*" on one side drops that side.
141
+ def day_match?(cur)
142
+ dom = @dom.matches?(cur.d)
143
+ dow = @dow.matches?(Time.utc(cur.y, cur.mo, cur.d).wday)
144
+ if @dom.star? && @dow.star?
145
+ true
146
+ elsif @dom.star?
147
+ dow
148
+ elsif @dow.star?
149
+ dom
150
+ else
151
+ dom || dow
152
+ end
153
+ end
154
+
155
+ Cursor = Struct.new(:y, :mo, :d, :h, :mi)
156
+
157
+ def advance_minute!(cur)
158
+ cur.mi += 1
159
+ if cur.mi > 59
160
+ cur.mi = 0
161
+ cur.h += 1
162
+ advance_day!(cur) if cur.h > 23
163
+ end
164
+ end
165
+
166
+ def advance_day!(cur)
167
+ cur.d += 1
168
+ return if cur.d <= days_in_month(cur.y, cur.mo)
169
+
170
+ cur.d = 1
171
+ cur.mo += 1
172
+ if cur.mo > 12
173
+ cur.mo = 1
174
+ cur.y += 1
175
+ end
176
+ end
177
+
178
+ def days_in_month(y, mo)
179
+ return 29 if mo == 2 && leap?(y)
180
+ return 28 if mo == 2
181
+
182
+ MONTHS_31.include?(mo) ? 31 : 30
183
+ end
184
+
185
+ def leap?(y)
186
+ y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)
187
+ end
188
+ end
189
+ end