insika 0.1.0 → 0.3.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 +199 -5
- data/README.md +8 -2
- data/bin/insika +231 -13
- data/docs/AGENTS.md +505 -6
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +100 -10
- data/docs/CONTEXT.md +147 -19
- data/docs/DEPLOY.md +34 -11
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +20 -1
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +17 -10
- data/docs/OBSERVABILITY.md +65 -2
- data/docs/REFINEMENT.md +9 -9
- data/docs/RELEASING.md +34 -7
- data/docs/RUNNING-LOCAL.md +4 -4
- data/docs/SECURITY.md +85 -11
- data/docs/SKILLS.md +189 -3
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +2 -2
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +228 -26
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +166 -0
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +132 -24
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +80 -6
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +9 -9
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +145 -13
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +6 -4
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/resolve_refinement.rb +1 -1
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/run_refinement.rb +5 -5
- data/lib/insika/commands/send_message.rb +112 -15
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +23 -5
- data/lib/insika/context/fragment.rb +31 -3
- data/lib/insika/context/priority.rb +6 -2
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +7 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +128 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +962 -7
- data/lib/insika/dsl/runtime.rb +20 -11
- data/lib/insika/dsl/server_boot.rb +74 -4
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +152 -15
- data/lib/insika/edge_limiter.rb +167 -8
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +22 -12
- data/lib/insika/errors.rb +72 -5
- data/lib/insika/evals/assertions.rb +15 -14
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +21 -9
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +1092 -160
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/frontmatter.rb +1 -1
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +3 -3
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +8 -3
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +8 -3
- data/lib/insika/outbox_store.rb +44 -6
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +34 -7
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +6 -3
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +10 -10
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +211 -0
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +46 -6
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +34 -115
- data/lib/insika/safety/factory.rb +18 -5
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +20 -5
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +10 -6
- data/lib/insika/safety/output_validator.rb +13 -7
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +366 -54
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +31 -7
- data/lib/insika/server/responses.rb +58 -9
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +11 -7
- data/lib/insika/session_store.rb +66 -3
- data/lib/insika/settings_store.rb +15 -5
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +131 -20
- data/lib/insika/skill_store.rb +70 -22
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/steer_injector.rb +1 -1
- data/lib/insika/store.rb +11 -2
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1058 -75
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +27 -26
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +274 -22
- data/lib/insika/studio/nav_icons.rb +7 -2
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +629 -86
- data/lib/insika/studio/views/agents.erb +11 -7
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +133 -3
- data/lib/insika/studio/views/settings.erb +9 -12
- data/lib/insika/studio/views/skills.erb +66 -12
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +22 -2
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +54 -33
- data/lib/insika/tick.rb +146 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +25 -15
- data/lib/insika/tool_envelope.rb +70 -1
- data/lib/insika/tool_manifest.rb +11 -7
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +14 -5
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +54 -13
- data/lib/insika/turn_timing.rb +24 -4
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +372 -34
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +122 -16
- metadata +95 -2
- data/lib/insika/server/admin_auth.rb +0 -29
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# — pure: confirmed answer text -> the balloons a progressive
|
|
5
|
+
# channel should POST. Paragraphs are the unit (`\n\n` is the seam E1 cares
|
|
6
|
+
# about); the sentence split fires only as a SOFT cap for one paragraph that
|
|
7
|
+
# grew past `SENTENCE_AFTER`. It never splits inside a fenced code block.
|
|
8
|
+
#
|
|
9
|
+
# No events, no outbox, no `:intermediate` — if it is handed loop narration
|
|
10
|
+
# that is a caller bug; the splitter has no way to tell.
|
|
11
|
+
module BalloonSplitter
|
|
12
|
+
# Soft cap after which a single paragraph is split on sentences. WhatsApp's
|
|
13
|
+
# hard cap is ~4096; this is a readability cap, not a platform one.
|
|
14
|
+
SENTENCE_AFTER = 600
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# -> [String], at least one when `text` is non-blank, else [].
|
|
19
|
+
def split(text)
|
|
20
|
+
raw = Insika::Coercion.presence(text)
|
|
21
|
+
return [] if raw.nil?
|
|
22
|
+
|
|
23
|
+
paragraphs = raw.split(PARAGRAPH_BREAK)
|
|
24
|
+
balloons = group_fenced(paragraphs)
|
|
25
|
+
balloons.flat_map { |balloon| split_long(balloon) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A fenced code block (` ``` ` … ` ``` `) spans paragraphs and stays ONE
|
|
29
|
+
# balloon — newlines inside it are code, not a seam. A fence that never
|
|
30
|
+
# closes still ends as one balloon (garbage in, one balloon out).
|
|
31
|
+
def group_fenced(paragraphs)
|
|
32
|
+
balloons = []
|
|
33
|
+
fence = nil
|
|
34
|
+
paragraphs.each do |para|
|
|
35
|
+
piece = para.strip
|
|
36
|
+
next if piece.empty?
|
|
37
|
+
|
|
38
|
+
if fence
|
|
39
|
+
fence << piece
|
|
40
|
+
# a line starting with ``` closes the block, wherever it lands — the
|
|
41
|
+
# close fence of ordinary markdown shares a paragraph with its code.
|
|
42
|
+
if closes_fence?(piece)
|
|
43
|
+
balloons << fence.join("\n\n")
|
|
44
|
+
fence = nil
|
|
45
|
+
end
|
|
46
|
+
elsif piece.lines.first.to_s.start_with?(FENCE)
|
|
47
|
+
fence = [piece]
|
|
48
|
+
# one paragraph may open AND close the block (```ruby\nx = 1\n```) —
|
|
49
|
+
# the closer is a later line of the same piece.
|
|
50
|
+
if closes_fence?(piece, opener: true)
|
|
51
|
+
balloons << fence.join("\n\n")
|
|
52
|
+
fence = nil
|
|
53
|
+
end
|
|
54
|
+
else
|
|
55
|
+
balloons << piece
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
balloons << fence.join("\n\n") if fence
|
|
59
|
+
balloons
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Does this paragraph close an open fence? Any line starting with the fence
|
|
63
|
+
# marker counts. `opener: true` skips the FIRST line — the line that opened
|
|
64
|
+
# the block, which cannot be its own closer.
|
|
65
|
+
def closes_fence?(piece, opener: false)
|
|
66
|
+
lines = piece.lines
|
|
67
|
+
lines[(opener ? 1 : 0)..].any? { |line| line.start_with?(FENCE) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The soft cap: a paragraph longer than SENTENCE_AFTER splits after sentence
|
|
71
|
+
# closers followed by whitespace — which is why `3.9s` and `www.` survive
|
|
72
|
+
# (their period is not followed by whitespace) and a decimal does too. A
|
|
73
|
+
# leftover without a closer stays one balloon.
|
|
74
|
+
#
|
|
75
|
+
# Sentences are then RE-GROUPED into ~SENTENCE_AFTER blocks (
|
|
76
|
+
# the split is a cap, not a mandate). Without the regroup, a 680-char
|
|
77
|
+
# paragraph of short sentences would atomize into one balloon per sentence —
|
|
78
|
+
# a paragraph that was ONE bubble becomes 40 WhatsApp messages for no
|
|
79
|
+
# latency win.
|
|
80
|
+
def split_long(balloon)
|
|
81
|
+
return [balloon] if balloon.length <= SENTENCE_AFTER
|
|
82
|
+
|
|
83
|
+
parts = balloon.split(SENTENCE_BOUNDARY).map(&:strip).reject(&:empty?)
|
|
84
|
+
return [balloon] if parts.empty?
|
|
85
|
+
|
|
86
|
+
parts.each_with_object([+""]) do |sentence, balloons|
|
|
87
|
+
if balloons.last.empty?
|
|
88
|
+
balloons.last << sentence
|
|
89
|
+
elsif balloons.last.length + sentence.length + 1 <= SENTENCE_AFTER
|
|
90
|
+
balloons.last << " #{sentence}"
|
|
91
|
+
else
|
|
92
|
+
balloons << +sentence
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
PARAGRAPH_BREAK = /\n\s*\n+/
|
|
98
|
+
FENCE = "```"
|
|
99
|
+
SENTENCE_BOUNDARY = /(?<=[.!?…])\s+(?=\S)/
|
|
100
|
+
private_constant :PARAGRAPH_BREAK, :FENCE, :SENTENCE_BOUNDARY
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
require "time"
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
|
-
# The ACCEPTED state of an agent's golden set (
|
|
7
|
-
# by
|
|
6
|
+
# The ACCEPTED state of an agent's golden set (promoted to a store
|
|
7
|
+
# by). One record per agent in the ConfigStore (scope "baselines"):
|
|
8
8
|
#
|
|
9
9
|
# { "at" => iso8601, "cases" => { "<case id>" => { "pass" => bool, "score" => n } } }
|
|
10
10
|
#
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Fixed-window spend counters for BUDGETS (WS2): the accounting layer the
|
|
5
|
+
# edge middleware rolls against. One scope, cells keyed
|
|
6
|
+
# "tenant:agent:window:calendar-bucket":
|
|
7
|
+
#
|
|
8
|
+
# · DAILY — the UTC calendar day (epoch/86400 IS midnight-aligned).
|
|
9
|
+
# · MONTHLY — (year * 12 + month) of the UTC calendar month: a budget month
|
|
10
|
+
# is the CALENDAR month, however many days long it is (a fixed
|
|
11
|
+
# N-day window drifts its bucket start across month lengths).
|
|
12
|
+
# UTC like the daily, so both rollovers agree on any host.
|
|
13
|
+
#
|
|
14
|
+
# Built on the UsageLedger vocabulary (tenant/agent instead of kind/id) but
|
|
15
|
+
# on the store directly, with the increment riding `@store.transaction` — the
|
|
16
|
+
# exact read-modify-write discipline WS2's enforcement will build on. Two
|
|
17
|
+
# processes (or two SQLite handles) racing the same cell serialize on
|
|
18
|
+
# BEGIN IMMEDIATE: no lost update. No enforcement here — the middleware is
|
|
19
|
+
# WS2; this file is only correct accounting.
|
|
20
|
+
#
|
|
21
|
+
# Growth is bounded on the hot path AND swept: each `add` deletes the (id)'s
|
|
22
|
+
# previous day and previous month cell (an ACTIVE scope holds at most 4 keys),
|
|
23
|
+
# and `prune` — the daily sweep on the tick — drops everything else: the cells
|
|
24
|
+
# of an id that went idle for more than one window and the alert flags, which
|
|
25
|
+
# the hot path never collects.
|
|
26
|
+
class BudgetLedger
|
|
27
|
+
SCOPE = "budget_counters"
|
|
28
|
+
ALERT_SCOPE = "budget_alerts"
|
|
29
|
+
DAY = 86_400
|
|
30
|
+
|
|
31
|
+
def initialize(store:)
|
|
32
|
+
@store = store
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Adds `by` across both windows; -> { daily:, monthly: } the NEW totals
|
|
36
|
+
# for (tenant, agent). Atomic per call: one transaction, both bumps.
|
|
37
|
+
def add(tenant:, agent:, by:, now: Time.now)
|
|
38
|
+
id = cell_id(tenant, agent)
|
|
39
|
+
@store.transaction do
|
|
40
|
+
daily = bump(id, daily_bucket(now), by)
|
|
41
|
+
monthly = bump(id, month_bucket(now), by)
|
|
42
|
+
@store.delete(SCOPE, key(id, daily_bucket(now - DAY))) # previous day cell
|
|
43
|
+
@store.delete(SCOPE, key(id, month_bucket(now) - 1)) # previous calendar month cell
|
|
44
|
+
{ daily: daily, monthly: monthly }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# -> { daily:, monthly: } current totals for (tenant, agent). Purely
|
|
49
|
+
# read; an expired window reads as 0 (rolls over at the boundary).
|
|
50
|
+
def current(tenant:, agent:, now: Time.now)
|
|
51
|
+
id = cell_id(tenant, agent)
|
|
52
|
+
{ daily: @store.get(SCOPE, key(id, daily_bucket(now))).to_i,
|
|
53
|
+
monthly: @store.get(SCOPE, key(id, month_bucket(now))).to_i }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Seconds until the window's bucket rolls over (the retry_after the
|
|
57
|
+
# enforcement quotes when a hard budget refuses a turn). Both windows are
|
|
58
|
+
# UTC-aligned (the daily via the epoch, the monthly via UTC components) so a
|
|
59
|
+
# non-UTC host never quotes a negative or local-midnight reset.
|
|
60
|
+
def reset_in(window, now: Time.now)
|
|
61
|
+
case window
|
|
62
|
+
when :daily then DAY - (now.to_i % DAY)
|
|
63
|
+
when :monthly then (next_utc_month_start(now) - now).to_i
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# "1× per window" alert markers (the soft enforcement's event): a flag per
|
|
68
|
+
# (id, window, level, bucket) so a budget that stays over the threshold
|
|
69
|
+
# cannot spam one event per turn. `level:` separates DISTINCT triggers in
|
|
70
|
+
# the same window (WS2): the `alert_at` crossing and the real soft-cap
|
|
71
|
+
# crossing each warn once — the cap event must not be swallowed by the
|
|
72
|
+
# 80% marker having fired earlier. Marked/read in the same transaction
|
|
73
|
+
# discipline. -> bool: had the window already been marked?
|
|
74
|
+
def mark_alert(tenant:, agent:, window:, level: nil, now: Time.now)
|
|
75
|
+
id = cell_id(tenant, agent)
|
|
76
|
+
flag = alert_key(id, window, now, level)
|
|
77
|
+
@store.transaction do
|
|
78
|
+
# `next`, NOT `return`: a non-local return from inside the block skips
|
|
79
|
+
# the store's COMMIT and leaks the BEGIN IMMEDIATE open — the 2nd turn
|
|
80
|
+
# over a threshold then locks the whole backend (WS2).
|
|
81
|
+
next true unless @store.get(ALERT_SCOPE, flag).nil?
|
|
82
|
+
|
|
83
|
+
@store.set(ALERT_SCOPE, flag, 1)
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def alerted?(tenant:, agent:, window:, level: nil, now: Time.now)
|
|
89
|
+
!@store.get(ALERT_SCOPE, alert_key(cell_id(tenant, agent), window, now, level)).nil?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The GC of both scopes: drops every cell whose window is not the CURRENT
|
|
93
|
+
# one. `add`'s two deletes only reach the IMMEDIATELY previous day/month, so
|
|
94
|
+
# a scope that goes idle for two days leaves its counter behind forever, and
|
|
95
|
+
# the alert flags were never collected at all — unbounded row growth the WS8
|
|
96
|
+
# retention sweep does not reach (that one is age-based over CONTENT; these
|
|
97
|
+
# are counters with no timestamp). Every key of both scopes ENDS in its
|
|
98
|
+
# bucket, so one rule sweeps both. -> count of cells removed.
|
|
99
|
+
def prune(now: Time.now)
|
|
100
|
+
day = daily_bucket(now)
|
|
101
|
+
month = month_bucket(now)
|
|
102
|
+
@store.transaction do
|
|
103
|
+
[SCOPE, ALERT_SCOPE].sum do |scope|
|
|
104
|
+
stale = @store.list(scope).select { |k| past?(k.rpartition(":").last.to_i, day, month) }
|
|
105
|
+
stale.each { |k| @store.delete(scope, k) }
|
|
106
|
+
stale.size
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# No tenant (single_tenant default) is a LITERAL "platform" cell, never a
|
|
114
|
+
# null-key collision with some other scope.
|
|
115
|
+
def cell_id(tenant, agent)
|
|
116
|
+
[tenant || "platform", agent].join(":")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Is that bucket a window STRICTLY BEHIND the live one? The two kinds of
|
|
120
|
+
# bucket cannot collide — an epoch-day is a multiple of 86_400 (~1.7e9), a
|
|
121
|
+
# calendar month is year*12+month (~24e3) — so the magnitude tells them
|
|
122
|
+
# apart. STRICTLY behind, never "not the current one": a host whose clock
|
|
123
|
+
# runs minutes ahead writes tomorrow's cell around midnight, and a sweeper
|
|
124
|
+
# that deleted it would hand that tenant a fresh day of budget.
|
|
125
|
+
def past?(bucket, day, month)
|
|
126
|
+
bucket >= DAY ? bucket < day : bucket.positive? && bucket < month
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def bump(id, bucket, by)
|
|
130
|
+
total = @store.get(SCOPE, key(id, bucket)).to_i + by
|
|
131
|
+
@store.set(SCOPE, key(id, bucket), total)
|
|
132
|
+
total
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# the UTC calendar day's start (epoch is aligned to midnight UTC).
|
|
136
|
+
def daily_bucket(now)
|
|
137
|
+
(now.to_i / DAY) * DAY
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# the UTC calendar month as one integer (2026-08 -> 24296). UTC, not local:
|
|
141
|
+
# the month boundary must agree with the daily epoch-day boundary on a
|
|
142
|
+
# non-UTC host (WS2), or the cap resets at a different moment than the day.
|
|
143
|
+
def month_bucket(now)
|
|
144
|
+
u = now.utc
|
|
145
|
+
u.year * 12 + u.month
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Midnight UTC of the 1st of the window's NEXT month — December-safe
|
|
149
|
+
# (Time.utc(y, 13, 1) raises; y+1/1 is the calendar answer).
|
|
150
|
+
def next_utc_month_start(now)
|
|
151
|
+
u = now.utc
|
|
152
|
+
u.month == 12 ? Time.utc(u.year + 1, 1, 1) : Time.utc(u.year, u.month + 1, 1)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def key(id, bucket)
|
|
156
|
+
"#{id}:#{bucket}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# One alert flag per (id, window, level, calendar bucket): daily cells are
|
|
160
|
+
# keyed by day, monthly by (year*12+month) — a flag dies with its window.
|
|
161
|
+
def alert_key(id, window, now, level = nil)
|
|
162
|
+
bucket = window == :monthly ? month_bucket(now) : daily_bucket(now)
|
|
163
|
+
level ? "#{id}:#{window}:#{level}:#{bucket}" : "#{id}:#{window}:#{bucket}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# — per-AGENT cache-hit series (scope "cache_series"), for the
|
|
5
|
+
# Studio agent-detail plot. Sessions do not stamp their agent, so the
|
|
6
|
+
# per-session context trace cannot answer "cache-hit over time for THIS
|
|
7
|
+
# agent"; this capped list can. Entries are counts and a category name only —
|
|
8
|
+
# PII-free by construction. No retention hook: the cap bounds growth.
|
|
9
|
+
class CacheSeriesStore
|
|
10
|
+
SCOPE = "cache_series"
|
|
11
|
+
MAX_PER_AGENT = 200 # oldest dropped; one entry per turn, so 200 is a
|
|
12
|
+
# rolling window, not a leak path
|
|
13
|
+
|
|
14
|
+
def initialize(store:)
|
|
15
|
+
@store = store
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Appends a sanitized entry for the agent; caps. Rescues everything — the
|
|
19
|
+
# series never breaks the turn.
|
|
20
|
+
def record(agent:, entry:)
|
|
21
|
+
return if agent.to_s.empty?
|
|
22
|
+
|
|
23
|
+
list = (@store.get(SCOPE, agent.to_s) || []) + [sanitize(entry)]
|
|
24
|
+
@store.set(SCOPE, agent.to_s, list.last(MAX_PER_AGENT))
|
|
25
|
+
rescue StandardError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# -> [Hash] the agent's series, chronological. [] if none.
|
|
30
|
+
def for_agent(agent) = @store.get(SCOPE, agent.to_s) || []
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def sanitize(entry)
|
|
35
|
+
e = entry.is_a?(Hash) ? entry : {}
|
|
36
|
+
{
|
|
37
|
+
"at" => (e[:at] || e["at"])&.to_s,
|
|
38
|
+
"turn" => int(e[:turn] || e["turn"]),
|
|
39
|
+
"hit_pct" => int_or_nil(e[:hit_pct] || e["hit_pct"]),
|
|
40
|
+
"cached_tokens" => int(e[:cached_tokens] || e["cached_tokens"]),
|
|
41
|
+
"prompt_tokens" => int(e[:prompt_tokens] || e["prompt_tokens"]),
|
|
42
|
+
"invalidation_reason" => (e[:invalidation_reason] || e["invalidation_reason"])&.to_s
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def int(value) = Integer(value || 0)
|
|
47
|
+
def int_or_nil(value) = value.nil? ? nil : Integer(value)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "time"
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
|
-
# Hands a finished turn's answer to a Shape B channel
|
|
6
|
+
# Hands a finished turn's answer to a Shape B channel. The turn
|
|
7
7
|
# ended; the recipient is not on any connection; the reply has to travel out of
|
|
8
8
|
# band and survive a crash on the way. Three moves, in this order, and the order
|
|
9
9
|
# is the whole design:
|
|
@@ -26,7 +26,8 @@ module Insika
|
|
|
26
26
|
BACKOFF_SECONDS = [1, 5].freeze
|
|
27
27
|
|
|
28
28
|
def initialize(channels:, outbox:, session_store:, event_stream: nil,
|
|
29
|
-
max_attempts: MAX_ATTEMPTS, backoff: BACKOFF_SECONDS, sleeper: nil
|
|
29
|
+
max_attempts: MAX_ATTEMPTS, backoff: BACKOFF_SECONDS, sleeper: nil,
|
|
30
|
+
shadow_pairs: nil, criterion_sha: nil)
|
|
30
31
|
@channels = channels
|
|
31
32
|
@outbox = outbox
|
|
32
33
|
@session_store = session_store
|
|
@@ -34,29 +35,66 @@ module Insika
|
|
|
34
35
|
@max_attempts = max_attempts
|
|
35
36
|
@backoff = Array(backoff)
|
|
36
37
|
@sleeper = sleeper || method(:default_sleep)
|
|
38
|
+
@shadow_pairs = shadow_pairs
|
|
39
|
+
@criterion_sha = criterion_sha
|
|
37
40
|
end
|
|
38
41
|
|
|
39
|
-
#
|
|
40
|
-
#
|
|
42
|
+
# the pair store and the frozen criterion's sha. Both default to
|
|
43
|
+
# nil (parity — a graph without them behaves exactly as today); the server
|
|
44
|
+
# root sets them at boot, after the criterion file has been loaded and
|
|
45
|
+
# refused-or-accepted (the graph itself reads no env and no file).
|
|
46
|
+
attr_writer :shadow_pairs, :criterion_sha
|
|
47
|
+
|
|
48
|
+
# Confirmed answer -> 0..N pending Deliveries, in order .
|
|
49
|
+
# A progressive channel splits on paragraphs (BalloonSplitter); everything
|
|
50
|
+
# else is the single whole-answer row.
|
|
51
|
+
# -> [] when there is nothing to send (the cheap exits):
|
|
41
52
|
# · the turn did not come in through a channel,
|
|
42
53
|
# · the channel is Shape A (answers on its own stream — no `deliver`),
|
|
43
54
|
# · the answer is empty (a turn that died mid-message published nothing, and
|
|
44
|
-
# half a sentence was never an answer
|
|
55
|
+
# half a sentence was never an answer),
|
|
56
|
+
# · the channel is in SHADOW mode: the answer is recorded as a
|
|
57
|
+
# pair and nothing is dispatched — zero outbox writes, ever (E1),
|
|
45
58
|
# · or we do not know who to send it to.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
59
|
+
#
|
|
60
|
+
# `attachments` (evidence cards) ride the outbox payload as an
|
|
61
|
+
# ADDITIVE key on the LAST balloon — a Shape B channel that reads `payload`
|
|
62
|
+
# ignores it (JSON contract, additive); one that renders cards consumes it.
|
|
63
|
+
def record_balloons(task:, channel_id:, content:, progressive:, attachments: nil)
|
|
49
64
|
channel = @channels&.find(channel_id)
|
|
50
|
-
return
|
|
65
|
+
return [] unless channel.respond_to?(:deliver)
|
|
66
|
+
# Shadow records ONE pair for the whole answer — a balloon per paragraph
|
|
67
|
+
# would mint N pairs for one turn.
|
|
68
|
+
if shadow?(channel)
|
|
69
|
+
record_shadow(task, channel_id, content)
|
|
70
|
+
return []
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
return [] if content.to_s.strip.empty?
|
|
51
74
|
|
|
52
75
|
to = recipient(channel, task.session_id)
|
|
53
|
-
return
|
|
76
|
+
return [] if to.nil? || to.empty?
|
|
77
|
+
|
|
78
|
+
parts = progressive ? Insika::BalloonSplitter.split(content) : [content.to_s]
|
|
79
|
+
parts = parts.reject { |p| p.to_s.strip.empty? }
|
|
80
|
+
return [] if parts.empty?
|
|
81
|
+
|
|
82
|
+
multi = parts.size > 1
|
|
83
|
+
parts.each_with_index.map do |part, i|
|
|
84
|
+
last = i == parts.size - 1
|
|
85
|
+
create_pending(task, channel_id, part, to,
|
|
86
|
+
index: multi ? i : nil, final: multi ? last : nil,
|
|
87
|
+
attachments: last ? attachments : nil)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def shadow?(channel) = channel.respond_to?(:shadow?) && channel.shadow?
|
|
54
92
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
)
|
|
93
|
+
# does this channel flush progressively? Duck-typed — a channel
|
|
94
|
+
# that does not answer `progressive?` is `:at_end`.
|
|
95
|
+
def progressive?(channel_id)
|
|
96
|
+
channel = @channels&.find(channel_id)
|
|
97
|
+
channel.respond_to?(:progressive?) && channel.progressive?
|
|
60
98
|
end
|
|
61
99
|
|
|
62
100
|
# Claim + POST + bounded retry. Safe to call twice: the second caller loses the
|
|
@@ -90,6 +128,73 @@ module Insika
|
|
|
90
128
|
|
|
91
129
|
private
|
|
92
130
|
|
|
131
|
+
# One outbox row for a confirmed balloon. `index`/`final` ride the payload
|
|
132
|
+
# only when non-nil — a single-balloon progressive turn is indistinguishable
|
|
133
|
+
# from an `:at_end` one on the wire. `index` also lands on the RECORD, which
|
|
134
|
+
# is what the boot sweep orders by. `attachments` :
|
|
135
|
+
# validated for the outbox — malformed entries dropped, never a turn failure.
|
|
136
|
+
def create_pending(task, channel_id, content, to, index: nil, final: nil, attachments: nil)
|
|
137
|
+
payload = { "session_id" => task.session_id.to_s, "task_id" => task.id.to_s,
|
|
138
|
+
"content" => content.to_s }
|
|
139
|
+
payload["index"] = index if index
|
|
140
|
+
payload["final"] = final unless final.nil?
|
|
141
|
+
atts = Insika::Evidence.valid_attachments(attachments)
|
|
142
|
+
payload["attachments"] = atts unless atts.empty?
|
|
143
|
+
@outbox.create(channel: channel_id, to: to, task_id: task.id, session_id: task.session_id,
|
|
144
|
+
payload: payload, index: index.to_i)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Our half of the shadow pair (C3). One store upsert on the turn's terminal,
|
|
148
|
+
# then nil — `Executor#finalize_channel_delivery` returns without dispatching.
|
|
149
|
+
# The ordering rules: no event_id -> :shadow_unpairable (C1 makes this
|
|
150
|
+
# unreachable through the relay; a plugin channel could still get it wrong);
|
|
151
|
+
# no pair store wired -> the same event (fail-closed, nothing delivered);
|
|
152
|
+
# no recipient -> the same event (a pair keyed on an empty external_id can
|
|
153
|
+
# never meet the mirror's half).
|
|
154
|
+
def record_shadow(task, channel_id, content)
|
|
155
|
+
command = task.respond_to?(:command) ? task.command : nil
|
|
156
|
+
payload = command.is_a?(Hash) ? (command["payload"] || command[:payload] || {}) : {}
|
|
157
|
+
agent = payload["agent"] || payload[:agent]
|
|
158
|
+
message = payload["message"] || payload[:message]
|
|
159
|
+
event_id = Insika::Coercion.presence(payload["event_id"] || payload[:event_id])
|
|
160
|
+
if event_id.nil? || @shadow_pairs.nil?
|
|
161
|
+
emit_shadow(:shadow_unpairable, channel_id, agent, nil, silent: nil)
|
|
162
|
+
return nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
channel = @channels&.find(channel_id)
|
|
166
|
+
external_id = recipient(channel, task.session_id)
|
|
167
|
+
# The same empty-recipient guard the delivery path has: a pair keyed on an
|
|
168
|
+
# empty external_id can never meet the mirror's half (its digest differs),
|
|
169
|
+
# so the pair would sit :open forever. C1 makes this unreachable through
|
|
170
|
+
# the relay; a plugin channel could still get it wrong.
|
|
171
|
+
if Insika::Coercion.presence(external_id).nil?
|
|
172
|
+
emit_shadow(:shadow_unpairable, channel_id, agent, nil, silent: nil)
|
|
173
|
+
return nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
silent = content.to_s.strip.empty?
|
|
177
|
+
id = Insika::ShadowPairStore.key_for(channel: channel_id, external_id: external_id,
|
|
178
|
+
event_id: event_id)
|
|
179
|
+
@shadow_pairs.record_ours(id: id, channel: channel_id, agent: agent,
|
|
180
|
+
session_id: task.session_id, task_id: task.id,
|
|
181
|
+
event_id: event_id, inbound: message.to_s,
|
|
182
|
+
reply: content.to_s, criterion_sha: @criterion_sha)
|
|
183
|
+
emit_shadow(:shadow_recorded, channel_id, agent, id, silent: silent)
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Metadata only: the stream reaches every subscriber and stays free of
|
|
188
|
+
# customer content, per the Studio's own emit_operator_action rule.
|
|
189
|
+
def emit_shadow(type, channel, agent, pair_id, silent:)
|
|
190
|
+
return unless @event_stream
|
|
191
|
+
|
|
192
|
+
data = { channel: channel.to_s, agent: agent, pair_id: pair_id }.compact
|
|
193
|
+
data[:silent] = silent unless silent.nil?
|
|
194
|
+
@event_stream.emit(Insika::Event.new(type: type, data: data,
|
|
195
|
+
meta: { at: Time.now.utc.iso8601 }))
|
|
196
|
+
end
|
|
197
|
+
|
|
93
198
|
def attempt(delivery, channel)
|
|
94
199
|
last_error = nil
|
|
95
200
|
|
|
@@ -111,7 +216,7 @@ module Insika
|
|
|
111
216
|
end
|
|
112
217
|
|
|
113
218
|
# The consumer's own key for this conversation. Written into the session's vars
|
|
114
|
-
# when the channel minted it
|
|
219
|
+
# when the channel minted it; the channel's own id parser is the fallback
|
|
115
220
|
# for a session created before those vars existed.
|
|
116
221
|
def recipient(channel, session_id)
|
|
117
222
|
session = session_id && @session_store.find(session_id)
|
|
@@ -130,14 +235,17 @@ module Insika
|
|
|
130
235
|
def emit(delivery)
|
|
131
236
|
return unless @event_stream
|
|
132
237
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
238
|
+
data = { channel: delivery.channel, outbox_id: delivery.id,
|
|
239
|
+
status: delivery.status.to_s, attempts: delivery.attempts,
|
|
240
|
+
error: delivery.last_error }
|
|
241
|
+
meta = { task_id: delivery.task_id, session_id: delivery.session_id,
|
|
242
|
+
at: Time.now.utc.iso8601 }
|
|
243
|
+
# :delivery_failed is the ALERT face of a failed delivery (WS6) — emitted
|
|
244
|
+
# alongside :channel_delivered so the delivery audit stream is unchanged.
|
|
245
|
+
@event_stream.emit(Insika::Event.new(type: :channel_delivered, data: data, meta: meta))
|
|
246
|
+
if delivery.status == :failed
|
|
247
|
+
@event_stream.emit(Insika::Event.new(type: :delivery_failed, data: data, meta: meta))
|
|
248
|
+
end
|
|
141
249
|
end
|
|
142
250
|
|
|
143
251
|
# Async when there is a reactor (production: the retry must not block the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
|
-
# The channels this deployment speaks, by id
|
|
4
|
+
# The channels this deployment speaks, by id. A `Registry` like
|
|
5
5
|
# tools and workflows — same plugin bookkeeping, so `deregister_plugin` rolls a
|
|
6
6
|
# half-registered plugin back exactly as it does for a tool.
|
|
7
7
|
#
|