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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +296 -0
- data/README.md +48 -12
- data/bin/insika +725 -0
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +116 -406
- data/docs/API.md +5 -5
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +137 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +14 -14
- data/docs/CONTEXT.md +63 -19
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +87 -10
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +128 -3
- data/docs/FACTS.md +3 -3
- data/docs/HARVEST.md +5 -6
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +17 -29
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +46 -12
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +222 -0
- data/docs/REFINEMENT.md +14 -9
- data/docs/RELEASING.md +4 -4
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +5 -5
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +23 -7
- data/docs/SKILLS.md +11 -2
- data/docs/SOAK.md +3 -3
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +176 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +4 -4
- data/docs/improve.md +20 -0
- data/docs/index.md +8 -5
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +99 -17
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/channel_delivery.rb +1 -1
- data/lib/insika/chat_builder.rb +50 -19
- data/lib/insika/commands/agent_payload.rb +2 -2
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +15 -3
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/compaction.rb +196 -0
- data/lib/insika/context/builder.rb +6 -2
- data/lib/insika/context/fragment.rb +4 -1
- data/lib/insika/context/priority.rb +8 -0
- data/lib/insika/context/providers/briefing.rb +53 -24
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/prompt.rb +30 -24
- data/lib/insika/context/providers/session.rb +46 -10
- data/lib/insika/context_trace_store.rb +11 -1
- data/lib/insika/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/doctor.rb +280 -17
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +64 -79
- data/lib/insika/dsl/server_boot.rb +23 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +103 -2
- data/lib/insika/env_schema.rb +21 -7
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +11 -0
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +84 -2
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/executor.rb +295 -55
- data/lib/insika/followup_policy.rb +2 -25
- data/lib/insika/golden_store.rb +16 -1
- data/lib/insika/grounding/matcher.rb +1 -1
- data/lib/insika/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/loop_detector.rb +5 -34
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +115 -31
- data/lib/insika/message_origin.rb +1 -1
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/onboarding.rb +17 -1
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +2 -2
- data/lib/insika/profile_source.rb +15 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/retention.rb +36 -1
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -0
- data/lib/insika/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/server/app.rb +105 -15
- data/lib/insika/server/rack_app.rb +5 -1
- data/lib/insika/server/responses.rb +5 -5
- data/lib/insika/session_store.rb +34 -4
- data/lib/insika/settings_store.rb +8 -1
- data/lib/insika/skill_catalog.rb +12 -0
- data/lib/insika/soak/runner.rb +4 -4
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/studio/app.rb +591 -47
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +21 -21
- data/lib/insika/studio/forms.rb +57 -5
- data/lib/insika/studio/nav_icons.rb +14 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +61 -820
- data/lib/insika/studio/views/agents.erb +70 -57
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/evals.erb +2 -2
- data/lib/insika/studio/views/facts.erb +1 -1
- data/lib/insika/studio/views/funnel.erb +1 -1
- data/lib/insika/studio/views/home.erb +106 -67
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +14 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/session.erb +231 -177
- data/lib/insika/studio/views/settings.erb +50 -1
- data/lib/insika/studio/views/skills.erb +1 -1
- data/lib/insika/studio/views/tools.erb +24 -9
- data/lib/insika/telemetry/recorder.rb +49 -1
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +47 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +68 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/tick.rb +24 -12
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tool_batch.rb +67 -0
- data/lib/insika/tool_usage_report.rb +162 -0
- data/lib/insika/tools/generate_image.rb +52 -7
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -0
- data/lib/insika/turn_budget.rb +91 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +15 -4
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +184 -12
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +64 -0
- metadata +109 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
data/lib/insika/agent_profile.rb
CHANGED
|
@@ -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
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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:
|
|
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
|
|
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
|
data/lib/insika/chat_builder.rb
CHANGED
|
@@ -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.
|
|
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
|
-
#
|
|
326
|
-
# (MRI fibers do not preempt between the read
|
|
327
|
-
# correlation is NOT — it lives in fiber
|
|
328
|
-
# each call gets its own fiber once tool
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
382
|
-
#
|
|
383
|
-
|
|
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
|
|