insika 0.1.0 → 0.2.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 +69 -3
- data/README.md +1 -1
- data/bin/insika +22 -7
- data/docs/AGENTS.md +129 -5
- data/docs/CHANNELS.md +1 -1
- data/docs/CONTEXT.md +22 -5
- data/docs/DEPLOY.md +30 -10
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +1 -1
- data/docs/LOADTEST.md +3 -2
- data/docs/OBSERVABILITY.md +11 -2
- data/docs/REFINEMENT.md +6 -6
- data/docs/RELEASING.md +7 -7
- data/docs/RUNNING-LOCAL.md +1 -1
- data/docs/SECURITY.md +24 -11
- data/docs/SKILLS.md +189 -3
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/index.md +1 -1
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +89 -22
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/channel_delivery.rb +14 -11
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +3 -3
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +7 -7
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +62 -13
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +5 -3
- 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/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/resolve_refinement.rb +1 -1
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +5 -5
- data/lib/insika/commands/send_message.rb +9 -9
- 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/context/builder.rb +2 -2
- data/lib/insika/context/fragment.rb +27 -3
- data/lib/insika/context/priority.rb +3 -2
- data/lib/insika/context/providers/memory.rb +1 -1
- 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 +5 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/doctor.rb +250 -5
- data/lib/insika/dsl/runtime.rb +12 -9
- data/lib/insika/dsl/server_boot.rb +4 -3
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +72 -15
- data/lib/insika/edge_limiter.rb +144 -6
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +13 -10
- data/lib/insika/errors.rb +61 -5
- data/lib/insika/evals/assertions.rb +12 -12
- 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 +3 -3
- 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/executor.rb +423 -108
- data/lib/insika/frontmatter.rb +1 -1
- data/lib/insika/golden_store.rb +2 -2
- 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/message_origin.rb +2 -2
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/onboarding.rb +2 -2
- data/lib/insika/outbox_store.rb +2 -2
- 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/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/profile_source.rb +12 -6
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +2 -2
- 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 +9 -9
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +2 -2
- data/lib/insika/safety/detectors.rb +5 -5
- data/lib/insika/safety/factory.rb +3 -3
- data/lib/insika/safety/input_guardrail.rb +19 -4
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +2 -2
- data/lib/insika/safety/output_validator.rb +2 -2
- 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/server/app.rb +223 -51
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +15 -7
- data/lib/insika/server/responses.rb +18 -8
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +3 -3
- data/lib/insika/session_store.rb +1 -1
- data/lib/insika/settings_store.rb +5 -5
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +127 -20
- data/lib/insika/skill_store.rb +70 -22
- data/lib/insika/steer_injector.rb +1 -1
- data/lib/insika/store.rb +1 -1
- data/lib/insika/studio/app.rb +183 -61
- data/lib/insika/studio/assets/dist/application.js +25 -24
- data/lib/insika/studio/forms.rb +13 -18
- data/lib/insika/studio/nav_icons.rb +1 -1
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +2 -2
- data/lib/insika/studio/views/agents.erb +1 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +78 -3
- data/lib/insika/studio/views/settings.erb +7 -12
- data/lib/insika/studio/views/skills.erb +67 -12
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +1 -1
- 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 +27 -27
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +8 -8
- data/lib/insika/tool_envelope.rb +1 -1
- data/lib/insika/tool_manifest.rb +6 -6
- 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 +4 -5
- data/lib/insika/tools/load_skill.rb +61 -3
- 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/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +17 -13
- data/lib/insika/turn_timing.rb +2 -2
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +77 -26
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +32 -15
- metadata +19 -2
- data/lib/insika/server/admin_auth.rb +0 -29
data/lib/insika/agent_profile.rb
CHANGED
|
@@ -16,11 +16,24 @@ module Insika
|
|
|
16
16
|
:id, :model, :provider,
|
|
17
17
|
:base_prompt, :prompt_files,
|
|
18
18
|
:tools_allow, :tools_deny,
|
|
19
|
-
:tools_allow_groups, # per-GROUP allowlist
|
|
19
|
+
:tools_allow_groups, # per-GROUP allowlist:
|
|
20
20
|
# union with tools_allow; deny wins; both
|
|
21
21
|
# nil = all (parity). Expands to the group's
|
|
22
22
|
# tools in the ToolAllowlist policy.
|
|
23
23
|
:skills,
|
|
24
|
+
:skills_eager, # progressive disclosure OFF, wholly or in part:
|
|
25
|
+
# nil/false = level 1 + load_skill (parity); true = every
|
|
26
|
+
# allowed skill; [names] = exactly these. An eager skill's
|
|
27
|
+
# BODY enters the prompt each turn, it leaves the
|
|
28
|
+
# <available_skills> catalog and load_skill refuses it.
|
|
29
|
+
# Removes the activation DECISION (no miss rate) at the
|
|
30
|
+
# cost of the bodies' tokens — measure them against
|
|
31
|
+
# context_budget before turning it on. Same opt-in as
|
|
32
|
+
# `memory`. It lives HERE and not in the SKILL.md
|
|
33
|
+
# frontmatter because skills are shared between agents:
|
|
34
|
+
# a per-skill flag forced one decision onto every
|
|
35
|
+
# allowlist holding the skill. NOT `Allowlist`
|
|
36
|
+
# semantics — nil means NONE here (SkillCatalog#eager_for).
|
|
24
37
|
:context_providers, # provider allowlist
|
|
25
38
|
:workflows_allow, # applied by WorkflowAllowlist
|
|
26
39
|
:policies, # names in the Policy Registry
|
|
@@ -30,7 +43,7 @@ module Insika
|
|
|
30
43
|
:capabilities, # intents the agent can trigger.
|
|
31
44
|
# nil = NONE (opt-in, see above).
|
|
32
45
|
:subagents, # allowlist of child agent ids this agent MAY spawn
|
|
33
|
-
#
|
|
46
|
+
# CAPACITY field — NEVER inherits;
|
|
34
47
|
# opt-in like `capabilities`: nil/absent = NONE (do NOT
|
|
35
48
|
# "fix" to nil = all). Present => the `spawn_subagent`
|
|
36
49
|
# system tool is wired (ChatBuilder), gated by this set.
|
|
@@ -40,7 +53,7 @@ module Insika
|
|
|
40
53
|
:memory, # cross-session memory.
|
|
41
54
|
# nil/false = OFF (parity: provider []; the `remember`
|
|
42
55
|
# tool not wired); true = ON. Same opt-in as capabilities.
|
|
43
|
-
:prompt_caching, # Anthropic prompt caching (
|
|
56
|
+
:prompt_caching, # Anthropic prompt caching (R3): nil/false = OFF
|
|
44
57
|
# (parity); true = ON. Same opt-in as `memory`. When ON
|
|
45
58
|
# AND the resolved provider is Anthropic, ChatBuilder sets
|
|
46
59
|
# ONE cache breakpoint at the end of the system block
|
|
@@ -50,34 +63,79 @@ module Insika
|
|
|
50
63
|
# a context provider injecting volatile content into
|
|
51
64
|
# :system turns every turn into a paid cache WRITE with
|
|
52
65
|
# no read hit. Enable only for stable-system agents.
|
|
53
|
-
:
|
|
66
|
+
:tool_output_compression, # MECHANICAL tool-result dedupe in the replayed
|
|
67
|
+
# history (A3/C3): nil/false = OFF (parity); true = ON.
|
|
68
|
+
# Same opt-in as `memory`. When ON, the history the
|
|
69
|
+
# Session provider seeds replaces byte-identical repeated
|
|
70
|
+
# tool results with a compact back-reference (first
|
|
71
|
+
# occurrence stays full) — no LLM involved. CHANGES WHAT
|
|
72
|
+
# THE MODEL SEES: an older full result is only the first
|
|
73
|
+
# occurrence; a model that wants an older detail re-calls
|
|
74
|
+
# the tool. Cheap half of compaction for bloated histories.
|
|
75
|
+
:params, # LLM generation params: a Hash with
|
|
54
76
|
# temperature/max_tokens/thinking, applied to the chat at
|
|
55
77
|
# stage 5. {} = provider defaults (parity).
|
|
56
|
-
:
|
|
78
|
+
:budget, # spend caps per (tenant, agent) over
|
|
79
|
+
# CALENDAR windows (WS2): { "daily" => int,
|
|
80
|
+
# "monthly" => int, "soft" => bool, "alert_at" => 0.8 }.
|
|
81
|
+
# HARD is the default: absent/"soft": false, a turn
|
|
82
|
+
# arriving at/over the cap fails with Insika::BudgetExceeded
|
|
83
|
+
# and the envelope quotes `budget_exceeded` + retry_after.
|
|
84
|
+
# "soft": true crosses the cap and still runs
|
|
85
|
+
# (one budget_warning event per window + a note in the
|
|
86
|
+
# context).
|
|
87
|
+
# Tokens count the billed spend (input+output+cached+
|
|
88
|
+
# cache_creation). nil/absent = no budget (parity).
|
|
89
|
+
:reliability, # the provider-interaction reliability policy (WS3):
|
|
90
|
+
# { "retries" => 3, "backoff" => "exponential",
|
|
91
|
+
# "fallback" => ["gpt-4o-mini", ...],
|
|
92
|
+
# "circuit_breaker" => { "after" => 10, "within" => 60,
|
|
93
|
+
# "cooldown" => 300 }, "timeout" => 30 }. Data, never
|
|
94
|
+
# DSL: retries + exponential backoff on :retryable /
|
|
95
|
+
# :rate_limited_* failures (never :fatal), mid-turn
|
|
96
|
+
# rotation across the fallback chain (profile's first,
|
|
97
|
+
# then the platform's resolved fallbacks), and a circuit
|
|
98
|
+
# breaker per (tenant, provider/model) that fail-fasts
|
|
99
|
+
# with circuit_open + retry_after once the window count
|
|
100
|
+
# trips. nil/absent = the plain single attempt (parity).
|
|
101
|
+
:alerts, # operator alert delivery (WS6): { "webhook" => url }.
|
|
102
|
+
# When present, the agent's budget_warning /
|
|
103
|
+
# breaker_open / delivery_failed events are POSTed to
|
|
104
|
+
# the URL as JSON (outbox + claim, at-most-once).
|
|
105
|
+
# nil/absent = no webhook (parity).
|
|
106
|
+
:stuck_signal, # the agent may signal it cannot proceed (WS5):
|
|
107
|
+
# nil/false = OFF (parity — the signal_stuck system
|
|
108
|
+
# tool is not wired); true = ON (the model may call
|
|
109
|
+
# signal_stuck, which ends the turn with
|
|
110
|
+
# `outcome: :stuck` + a final message + a :turn_stuck
|
|
111
|
+
# event the consumer acts on). Same opt-in as
|
|
112
|
+
# `memory`. What "stuck" MEANS is the consumer's call
|
|
113
|
+
# (escalation via CRM/operator), never the engine's.
|
|
114
|
+
:model_policy, # governance of WHICH models the agent may use:
|
|
57
115
|
# { "allow" => [refs] }. nil = NO fence (all models —
|
|
58
116
|
# parity). Enforced on the RESOLVED model (ModelResolver).
|
|
59
|
-
:guardrails, # content-safety config
|
|
117
|
+
:guardrails, # content-safety config: { input:, output:,
|
|
60
118
|
# moderator:, strictness: }. OPT-IN like capabilities —
|
|
61
119
|
# nil/absent = the conservative default (Safety::Config:
|
|
62
120
|
# deterministic on, moderator off). Parsed, never a policy.
|
|
63
|
-
:sandbox, # confined-execution config
|
|
121
|
+
:sandbox, # confined-execution config:
|
|
64
122
|
# { provider: "local"|"docker", root:, timeout:, ...+provider
|
|
65
123
|
# keys }. Declarative provider selection (config-over-code) —
|
|
66
124
|
# consumed by Insika::Sandbox.build. {} = absent (a
|
|
67
125
|
# deployment builds a `local` sandbox by default). It is
|
|
68
126
|
# CONFIG, never a policy — it does not decide security by
|
|
69
127
|
# itself; the FS boundary + approvals do.
|
|
70
|
-
:refinement, # self-improvement config
|
|
128
|
+
:refinement, # self-improvement config:
|
|
71
129
|
# { mode: "report"|"propose"|"auto_apply", window: {…},
|
|
72
130
|
# files: [allowlist], proposers: [refs], budget: {tokens:},
|
|
73
131
|
# auto_apply_max_edits:, max_findings:, … }. nil/absent =
|
|
74
|
-
# REPORT-ONLY (
|
|
132
|
+
# REPORT-ONLY (writes nothing to the agent, so
|
|
75
133
|
# reading your own traces needs no opt-in); `propose`
|
|
76
134
|
# and above must be enabled explicitly. It is CONFIG,
|
|
77
135
|
# never a policy — the write allowlist it carries is
|
|
78
136
|
# enforced by the applier, not by this field.
|
|
79
137
|
:capabilities_declared, # FACTS ABOUT THIS DEPLOYMENT that are not tools
|
|
80
|
-
#
|
|
138
|
+
# %w[promotions human_handoff
|
|
81
139
|
# b2b_pricing]. An eval case declares what it
|
|
82
140
|
# `requires` and is SKIPPED — never failed — where
|
|
83
141
|
# the deployment lacks it, which is what makes one
|
|
@@ -106,7 +164,7 @@ module Insika
|
|
|
106
164
|
# default to inherit.
|
|
107
165
|
:metadata # free-form agent metadata, stable per agent
|
|
108
166
|
# (from the pack `agent.config.json`). Home of the `store_id`
|
|
109
|
-
# that becomes turn context (ctx.store_id
|
|
167
|
+
# that becomes turn context (ctx.store_id).
|
|
110
168
|
# It is NOT a policy — never decides security. {} = absent.
|
|
111
169
|
)
|
|
112
170
|
|
|
@@ -116,29 +174,34 @@ module Insika
|
|
|
116
174
|
DEFAULT_LIMITS = {
|
|
117
175
|
turn_timeout: 300, tool_timeout: 60, provider_timeout: 5,
|
|
118
176
|
context_budget: 8_000, max_tool_calls: 50,
|
|
177
|
+
# consecutive identical (tool, args) calls that trigger the ONE
|
|
178
|
+
# loop warning; a repeat after it aborts like max_tool_calls. < 2 = off.
|
|
179
|
+
max_tool_repeat: 3,
|
|
119
180
|
approval_timeout: 3_600, # cap on the wait for human approval (~1h)
|
|
120
|
-
#
|
|
181
|
+
# parallel tool calls. ONE number is both the switch and the cap
|
|
121
182
|
# (nil/0/1 = serial, the default; N > 1 = at most N tool calls in flight).
|
|
122
183
|
# It sits next to tool_timeout/max_tool_calls because it is the third bound
|
|
123
184
|
# on tool execution. Read through TurnState#tool_concurrency, which also
|
|
124
|
-
# applies the approval gate
|
|
185
|
+
# applies the approval gate.
|
|
125
186
|
tool_concurrency: 1
|
|
126
187
|
}.freeze
|
|
127
188
|
|
|
128
|
-
# `model` is OPTIONAL as of v2
|
|
189
|
+
# `model` is OPTIONAL as of v2: an agent without one resolves the
|
|
129
190
|
# platform `default_model` (Settings) at turn start via the ModelResolver.
|
|
130
191
|
def self.build(id:, model: nil, provider: nil, base_prompt: "", prompt_files: [],
|
|
131
192
|
tools_allow: nil, tools_deny: [], tools_allow_groups: nil, skills: nil,
|
|
132
|
-
context_providers: nil, workflows_allow: nil,
|
|
193
|
+
skills_eager: nil, context_providers: nil, workflows_allow: nil,
|
|
133
194
|
policies: [], prompt_refs: [], limits: {}, approvals_required: nil,
|
|
134
195
|
capabilities: nil, subagents: nil, tools_deferred: nil, memory: nil,
|
|
135
|
-
prompt_caching: nil,
|
|
196
|
+
prompt_caching: nil, tool_output_compression: nil,
|
|
136
197
|
params: {}, model_policy: nil, guardrails: nil, sandbox: nil,
|
|
137
|
-
refinement: nil, capabilities_declared: nil, edge_stream: nil, metadata: {}
|
|
198
|
+
refinement: nil, capabilities_declared: nil, edge_stream: nil, metadata: {},
|
|
199
|
+
budget: nil, reliability: nil, alerts: nil, stuck_signal: nil)
|
|
138
200
|
new(
|
|
139
201
|
id: id, model: model, provider: provider, base_prompt: base_prompt,
|
|
140
202
|
prompt_files: Array(prompt_files), tools_allow: tools_allow,
|
|
141
203
|
tools_deny: Array(tools_deny), tools_allow_groups: tools_allow_groups, skills: skills,
|
|
204
|
+
skills_eager: skills_eager,
|
|
142
205
|
context_providers: context_providers, workflows_allow: workflows_allow,
|
|
143
206
|
policies: Array(policies), prompt_refs: Array(prompt_refs),
|
|
144
207
|
limits: DEFAULT_LIMITS.merge(limits), approvals_required: approvals_required,
|
|
@@ -147,7 +210,7 @@ module Insika
|
|
|
147
210
|
# readers get a clean [] and the ChatBuilder gate (present? => wire) is stable.
|
|
148
211
|
subagents: subagents.nil? ? nil : Array(subagents).map(&:to_s),
|
|
149
212
|
tools_deferred: tools_deferred, memory: memory,
|
|
150
|
-
prompt_caching: prompt_caching,
|
|
213
|
+
prompt_caching: prompt_caching, tool_output_compression: tool_output_compression,
|
|
151
214
|
# The free-form hashes arrive with symbol keys (internal build) OR string
|
|
152
215
|
# keys (StoredProfileSource JSON round-trip). Normalize to string keys ONCE
|
|
153
216
|
# here — the single front door every profile passes through — so no reader
|
|
@@ -161,7 +224,11 @@ module Insika
|
|
|
161
224
|
# symbol/string mix there would be a silent miss.
|
|
162
225
|
capabilities_declared: Array(capabilities_declared).map(&:to_s),
|
|
163
226
|
edge_stream: Coercion.deep_stringify(edge_stream || {}),
|
|
164
|
-
metadata: Coercion.deep_stringify(metadata || {})
|
|
227
|
+
metadata: Coercion.deep_stringify(metadata || {}),
|
|
228
|
+
budget: Coercion.deep_stringify(budget),
|
|
229
|
+
reliability: Coercion.deep_stringify(reliability),
|
|
230
|
+
alerts: Coercion.deep_stringify(alerts),
|
|
231
|
+
stuck_signal: stuck_signal
|
|
165
232
|
)
|
|
166
233
|
end
|
|
167
234
|
|
|
@@ -173,8 +240,8 @@ module Insika
|
|
|
173
240
|
# store_id of the turn context (ctx.store_id): lives in `metadata` (stable
|
|
174
241
|
# per store, comes from the pack). `build` string-keys metadata, so a plain
|
|
175
242
|
# string lookup is enough. nil = absent (the data-tool emits an empty header).
|
|
176
|
-
# It is NOT
|
|
177
|
-
#
|
|
243
|
+
# It is NOT consumer-specific: `store_id` is a field of the turn-context contract
|
|
244
|
+
# generic per project.
|
|
178
245
|
def store_id = (metadata || {})["store_id"]
|
|
179
246
|
|
|
180
247
|
# May this channel (:thinking / :intermediate) cross to the customer? Tolerant
|
|
@@ -182,7 +249,7 @@ module Insika
|
|
|
182
249
|
# of anything else being absent: the safe reading is the default one.
|
|
183
250
|
def stream_public?(channel)
|
|
184
251
|
v = (edge_stream || {})[channel.to_s]
|
|
185
|
-
|
|
252
|
+
Coercion.truthy?(v)
|
|
186
253
|
end
|
|
187
254
|
end
|
|
188
255
|
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Operator alerts to a webhook (WS6): the events `:budget_warning`,
|
|
8
|
+
# `:breaker_open` and `:delivery_failed` are answered per AGENT — a profile
|
|
9
|
+
# declaring `alerts: { "webhook" => url }` gets its alerts POSTed there as
|
|
10
|
+
# JSON. The delivery reuses the outbox + claim mechanism whole
|
|
11
|
+
# (`ChannelDelivery`): the handler only WRITES the outbox row; the existing
|
|
12
|
+
# tick sweep and boot recovery claim and POST it, at-most-once with bounded
|
|
13
|
+
# retry, via a registered `Channels::Webhook`. The engine transports the event
|
|
14
|
+
# and does not interpret it — a Slack/CRM adapter is the consumer's.
|
|
15
|
+
#
|
|
16
|
+
# Started as a child of the turn supervisor (like the tick) in serving mode;
|
|
17
|
+
# tests drive `handle` directly.
|
|
18
|
+
class AlertDispatcher
|
|
19
|
+
ALERT_TYPES = %i[budget_warning breaker_open delivery_failed].freeze
|
|
20
|
+
|
|
21
|
+
def initialize(event_stream:, outbox:, channels:, profiles:, task_store: nil, http:)
|
|
22
|
+
@event_stream = event_stream
|
|
23
|
+
@outbox = outbox
|
|
24
|
+
@channels = channels
|
|
25
|
+
@profiles = profiles
|
|
26
|
+
@task_store = task_store
|
|
27
|
+
@http = http
|
|
28
|
+
@webhook_ids = {} # url -> registered channel id (one webhook per URL)
|
|
29
|
+
# WS6 (boot recovery): webhook channels are derived from PROFILE config,
|
|
30
|
+
# not from events. Registering lazily (on the first alert) means a pending
|
|
31
|
+
# outbox row a crashed process left is swept at boot against an EMPTY
|
|
32
|
+
# registry and marked failed terminal. Pre-registering every configured
|
|
33
|
+
# URL at wiring time lets the boot sweep find the channel and deliver.
|
|
34
|
+
register_all_webhooks
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Serving: a long-lived consumer that answers every alert event. Drains on
|
|
38
|
+
# the supervisor fiber (blocks on the queue — no spin), exactly like the tick.
|
|
39
|
+
# It subscribes TYPED (only the alert events enter its queue — it answers
|
|
40
|
+
# payloads a full-traffic stream would otherwise overflow away) and, on an
|
|
41
|
+
# overflow close, RE-SUBSCRIBES: a consumer that never re-binds is how alerts
|
|
42
|
+
# stop in silence (WS6).
|
|
43
|
+
def start(parent:)
|
|
44
|
+
parent.async do |t|
|
|
45
|
+
t.annotate("insika-alerts")
|
|
46
|
+
loop do
|
|
47
|
+
subscription = @event_stream.subscribe(types: ALERT_TYPES)
|
|
48
|
+
subscription.each { |event| handle(event) }
|
|
49
|
+
# the subscription closed (its overflow path) — alerts must not die here
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The event -> outbox row. Cheap (one transactional write); the DELIVERY is
|
|
56
|
+
# the tick's job. Never raises: an alerting failure must not break the turn.
|
|
57
|
+
def handle(event)
|
|
58
|
+
type = event.type.to_s.to_sym
|
|
59
|
+
return unless ALERT_TYPES.include?(type)
|
|
60
|
+
|
|
61
|
+
agent = agent_for(event)
|
|
62
|
+
return if agent.nil?
|
|
63
|
+
|
|
64
|
+
profile = @profiles.respond_to?(:fetch) ? @profiles.fetch(agent.to_s) : nil
|
|
65
|
+
return if profile.nil?
|
|
66
|
+
|
|
67
|
+
url = profile&.respond_to?(:alerts) ? profile.alerts&.dig("webhook") : nil
|
|
68
|
+
return if Coercion.blank?(url)
|
|
69
|
+
|
|
70
|
+
record_alert(agent: agent.to_s, url: url.to_s, event: event)
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# The agent the alert belongs to: the event carries it for the alerts the
|
|
78
|
+
# engine emits with context (budget_warning / breaker_open); a
|
|
79
|
+
# delivery_failed resolves its task's command. Guards the loop: a webhook's
|
|
80
|
+
# OWN delivery failing is not re-alerted.
|
|
81
|
+
def agent_for(event)
|
|
82
|
+
case event.type.to_sym
|
|
83
|
+
when :delivery_failed
|
|
84
|
+
channel = event.data[:channel]
|
|
85
|
+
return nil if channel.to_s.start_with?("webhook:") # loop guard
|
|
86
|
+
agent_for_task(event.meta[:task_id])
|
|
87
|
+
else
|
|
88
|
+
event.data[:agent] || agent_for_task(event.meta[:task_id])
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def agent_for_task(task_id)
|
|
93
|
+
return nil if task_id.nil? || @task_store.nil?
|
|
94
|
+
|
|
95
|
+
task = @task_store.find(task_id.to_s)
|
|
96
|
+
command = task&.respond_to?(:command) ? task.command : nil
|
|
97
|
+
return nil unless command.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
payload = command["payload"] || command[:payload] || {}
|
|
100
|
+
payload["agent"] || payload[:agent]
|
|
101
|
+
rescue StandardError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The event's durable record, as the CHANNEL would see it. `to` is the
|
|
106
|
+
# webhook URL; `payload` is the event itself (type/data/meta).
|
|
107
|
+
def record_alert(agent:, url:, event:)
|
|
108
|
+
channel = webhook_id(url)
|
|
109
|
+
@outbox.create(
|
|
110
|
+
channel: channel, to: url,
|
|
111
|
+
task_id: event.meta[:task_id], session_id: event.meta[:session_id],
|
|
112
|
+
payload: { "type" => event.type.to_s, "data" => event.data,
|
|
113
|
+
"meta" => event.meta, "agent" => agent }
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# One channel per URL, registered so ChannelDelivery.sweep can claim it.
|
|
118
|
+
def webhook_id(url)
|
|
119
|
+
@webhook_ids[url] ||= begin
|
|
120
|
+
id = "webhook:#{Digest::SHA1.hexdigest(url)[0, 8]}"
|
|
121
|
+
@channels.register(id, Channels::Webhook.new(url, http: @http))
|
|
122
|
+
id
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Boot face of `webhook_id`: register every configured URL up front (at
|
|
127
|
+
# wiring time, before the boot recovery's channel sweep runs). The url is
|
|
128
|
+
# PROFILE data, so it is known before any alert ever fires.
|
|
129
|
+
def register_all_webhooks
|
|
130
|
+
profiles = @profiles.respond_to?(:all) ? @profiles.all : []
|
|
131
|
+
profiles.each do |profile|
|
|
132
|
+
next unless profile&.respond_to?(:alerts)
|
|
133
|
+
|
|
134
|
+
url = profile.alerts&.dig("webhook")
|
|
135
|
+
webhook_id(url.to_s) unless Coercion.blank?(url)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
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,135 @@
|
|
|
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 like UsageLedger: each `add` deletes the (id)'s previous
|
|
22
|
+
# day AND previous month cell, so an active scope holds at most 4 keys and an
|
|
23
|
+
# idle one converges to 2.
|
|
24
|
+
class BudgetLedger
|
|
25
|
+
SCOPE = "budget_counters"
|
|
26
|
+
ALERT_SCOPE = "budget_alerts"
|
|
27
|
+
DAY = 86_400
|
|
28
|
+
|
|
29
|
+
def initialize(store:)
|
|
30
|
+
@store = store
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Adds `by` across both windows; -> { daily:, monthly: } the NEW totals
|
|
34
|
+
# for (tenant, agent). Atomic per call: one transaction, both bumps.
|
|
35
|
+
def add(tenant:, agent:, by:, now: Time.now)
|
|
36
|
+
id = cell_id(tenant, agent)
|
|
37
|
+
@store.transaction do
|
|
38
|
+
daily = bump(id, daily_bucket(now), by)
|
|
39
|
+
monthly = bump(id, month_bucket(now), by)
|
|
40
|
+
@store.delete(SCOPE, key(id, daily_bucket(now - DAY))) # previous day cell
|
|
41
|
+
@store.delete(SCOPE, key(id, month_bucket(now) - 1)) # previous calendar month cell
|
|
42
|
+
{ daily: daily, monthly: monthly }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# -> { daily:, monthly: } current totals for (tenant, agent). Purely
|
|
47
|
+
# read; an expired window reads as 0 (rolls over at the boundary).
|
|
48
|
+
def current(tenant:, agent:, now: Time.now)
|
|
49
|
+
id = cell_id(tenant, agent)
|
|
50
|
+
{ daily: @store.get(SCOPE, key(id, daily_bucket(now))).to_i,
|
|
51
|
+
monthly: @store.get(SCOPE, key(id, month_bucket(now))).to_i }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Seconds until the window's bucket rolls over (the retry_after the
|
|
55
|
+
# enforcement quotes when a hard budget refuses a turn). Both windows are
|
|
56
|
+
# UTC-aligned (the daily via the epoch, the monthly via UTC components) so a
|
|
57
|
+
# non-UTC host never quotes a negative or local-midnight reset.
|
|
58
|
+
def reset_in(window, now: Time.now)
|
|
59
|
+
case window
|
|
60
|
+
when :daily then DAY - (now.to_i % DAY)
|
|
61
|
+
when :monthly then (next_utc_month_start(now) - now).to_i
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# "1× per window" alert markers (the soft enforcement's event): a flag per
|
|
66
|
+
# (id, window, level, bucket) so a budget that stays over the threshold
|
|
67
|
+
# cannot spam one event per turn. `level:` separates DISTINCT triggers in
|
|
68
|
+
# the same window (WS2): the `alert_at` crossing and the real soft-cap
|
|
69
|
+
# crossing each warn once — the cap event must not be swallowed by the
|
|
70
|
+
# 80% marker having fired earlier. Marked/read in the same transaction
|
|
71
|
+
# discipline. -> bool: had the window already been marked?
|
|
72
|
+
def mark_alert(tenant:, agent:, window:, level: nil, now: Time.now)
|
|
73
|
+
id = cell_id(tenant, agent)
|
|
74
|
+
flag = alert_key(id, window, now, level)
|
|
75
|
+
@store.transaction do
|
|
76
|
+
# `next`, NOT `return`: a non-local return from inside the block skips
|
|
77
|
+
# the store's COMMIT and leaks the BEGIN IMMEDIATE open — the 2nd turn
|
|
78
|
+
# over a threshold then locks the whole backend (WS2).
|
|
79
|
+
next true unless @store.get(ALERT_SCOPE, flag).nil?
|
|
80
|
+
|
|
81
|
+
@store.set(ALERT_SCOPE, flag, 1)
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def alerted?(tenant:, agent:, window:, level: nil, now: Time.now)
|
|
87
|
+
!@store.get(ALERT_SCOPE, alert_key(cell_id(tenant, agent), window, now, level)).nil?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# No tenant (single_tenant default) is a LITERAL "platform" cell, never a
|
|
93
|
+
# null-key collision with some other scope.
|
|
94
|
+
def cell_id(tenant, agent)
|
|
95
|
+
[tenant || "platform", agent].join(":")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def bump(id, bucket, by)
|
|
99
|
+
total = @store.get(SCOPE, key(id, bucket)).to_i + by
|
|
100
|
+
@store.set(SCOPE, key(id, bucket), total)
|
|
101
|
+
total
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# the UTC calendar day's start (epoch is aligned to midnight UTC).
|
|
105
|
+
def daily_bucket(now)
|
|
106
|
+
(now.to_i / DAY) * DAY
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# the UTC calendar month as one integer (2026-08 -> 24296). UTC, not local:
|
|
110
|
+
# the month boundary must agree with the daily epoch-day boundary on a
|
|
111
|
+
# non-UTC host (WS2), or the cap resets at a different moment than the day.
|
|
112
|
+
def month_bucket(now)
|
|
113
|
+
u = now.utc
|
|
114
|
+
u.year * 12 + u.month
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Midnight UTC of the 1st of the window's NEXT month — December-safe
|
|
118
|
+
# (Time.utc(y, 13, 1) raises; y+1/1 is the calendar answer).
|
|
119
|
+
def next_utc_month_start(now)
|
|
120
|
+
u = now.utc
|
|
121
|
+
u.month == 12 ? Time.utc(u.year + 1, 1, 1) : Time.utc(u.year, u.month + 1, 1)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def key(id, bucket)
|
|
125
|
+
"#{id}:#{bucket}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# One alert flag per (id, window, level, calendar bucket): daily cells are
|
|
129
|
+
# keyed by day, monthly by (year*12+month) — a flag dies with its window.
|
|
130
|
+
def alert_key(id, window, now, level = nil)
|
|
131
|
+
bucket = window == :monthly ? month_bucket(now) : daily_bucket(now)
|
|
132
|
+
level ? "#{id}:#{window}:#{level}:#{bucket}" : "#{id}:#{window}:#{bucket}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
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:
|
|
@@ -41,7 +41,7 @@ module Insika
|
|
|
41
41
|
# · the turn did not come in through a channel,
|
|
42
42
|
# · the channel is Shape A (answers on its own stream — no `deliver`),
|
|
43
43
|
# · the answer is empty (a turn that died mid-message published nothing, and
|
|
44
|
-
# half a sentence was never an answer
|
|
44
|
+
# half a sentence was never an answer),
|
|
45
45
|
# · or we do not know who to send it to.
|
|
46
46
|
def record(task:, channel_id:, content:)
|
|
47
47
|
return nil if content.to_s.strip.empty?
|
|
@@ -111,7 +111,7 @@ module Insika
|
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
# The consumer's own key for this conversation. Written into the session's vars
|
|
114
|
-
# when the channel minted it
|
|
114
|
+
# when the channel minted it; the channel's own id parser is the fallback
|
|
115
115
|
# for a session created before those vars existed.
|
|
116
116
|
def recipient(channel, session_id)
|
|
117
117
|
session = session_id && @session_store.find(session_id)
|
|
@@ -130,14 +130,17 @@ module Insika
|
|
|
130
130
|
def emit(delivery)
|
|
131
131
|
return unless @event_stream
|
|
132
132
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
133
|
+
data = { channel: delivery.channel, outbox_id: delivery.id,
|
|
134
|
+
status: delivery.status.to_s, attempts: delivery.attempts,
|
|
135
|
+
error: delivery.last_error }
|
|
136
|
+
meta = { task_id: delivery.task_id, session_id: delivery.session_id,
|
|
137
|
+
at: Time.now.utc.iso8601 }
|
|
138
|
+
# :delivery_failed is the ALERT face of a failed delivery (WS6) — emitted
|
|
139
|
+
# alongside :channel_delivered so the delivery audit stream is unchanged.
|
|
140
|
+
@event_stream.emit(Insika::Event.new(type: :channel_delivered, data: data, meta: meta))
|
|
141
|
+
if delivery.status == :failed
|
|
142
|
+
@event_stream.emit(Insika::Event.new(type: :delivery_failed, data: data, meta: meta))
|
|
143
|
+
end
|
|
141
144
|
end
|
|
142
145
|
|
|
143
146
|
# 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
|
#
|
|
@@ -8,7 +8,7 @@ require "uri"
|
|
|
8
8
|
module Insika
|
|
9
9
|
module Channels
|
|
10
10
|
# The channel for an adopter who ALREADY owns a messaging integration
|
|
11
|
-
#
|
|
11
|
+
# A WhatsApp BSP, a Zendesk, a legacy Rails app: they want the
|
|
12
12
|
# engine for the TURN, not for the platform. Two routes and an envelope —
|
|
13
13
|
#
|
|
14
14
|
# consumer --POST /channels/relay/events--> engine acked now, never the reply
|
|
@@ -16,7 +16,7 @@ module Insika
|
|
|
16
16
|
#
|
|
17
17
|
# — and everything platform-shaped stays theirs: the 24-hour window, template
|
|
18
18
|
# approval, media, read receipts, and how markdown becomes WhatsApp formatting
|
|
19
|
-
#
|
|
19
|
+
# That is the promise, not the limitation: an integration someone has
|
|
20
20
|
# already tuned for years does not have to move for them to adopt the engine.
|
|
21
21
|
# A relay that starts growing template logic has stopped being a relay.
|
|
22
22
|
#
|
|
@@ -106,7 +106,7 @@ module Insika
|
|
|
106
106
|
event_id: presence(body["event_id"]), vars: vars }
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
-
#
|
|
109
|
+
# the engine namespaces the platform's conversation key, so a
|
|
110
110
|
# Slack channel id and a phone number can never collide, an operator can see
|
|
111
111
|
# where a conversation came from, and an id minted for one channel cannot be
|
|
112
112
|
# used to read another's session.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Insika web widget
|
|
1
|
+
// Insika web widget.
|
|
2
2
|
//
|
|
3
3
|
// One <script> tag, no framework, no build step, no dependency:
|
|
4
4
|
//
|
|
@@ -176,7 +176,7 @@
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
// The engine issues the session id; we only ever store the one it gave us
|
|
179
|
-
// (
|
|
179
|
+
// (a client that proposes its own id on a public endpoint is
|
|
180
180
|
// one enumeration away from reading someone else's conversation).
|
|
181
181
|
function session() {
|
|
182
182
|
var saved = remembered;
|