insika 0.2.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +325 -17
- data/README.md +52 -11
- data/bin/insika +895 -8
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +229 -163
- data/docs/API.md +56 -0
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +111 -21
- data/docs/CONTEXT.md +132 -19
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +75 -4
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +146 -2
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +116 -0
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +16 -10
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +65 -6
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +17 -12
- data/docs/RELEASING.md +27 -0
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +6 -6
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +79 -2
- data/docs/SKILLS.md +13 -4
- data/docs/SOAK.md +127 -0
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +220 -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 +115 -0
- data/docs/improve.md +20 -0
- data/docs/index.md +9 -6
- 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 +202 -10
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +119 -14
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +105 -2
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/cancel_followup.rb +49 -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 +107 -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 +1 -1
- 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/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +57 -23
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- 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/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +889 -11
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +69 -82
- data/lib/insika/dsl/server_boot.rb +92 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +186 -5
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +25 -3
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +29 -6
- 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 +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +869 -76
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +198 -0
- data/lib/insika/followup_store.rb +306 -0
- 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 +17 -2
- 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/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- 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 +382 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +7 -2
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +22 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +37 -17
- 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/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +29 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +316 -0
- 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/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- 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 +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- 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/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +247 -17
- data/lib/insika/server/rack_app.rb +21 -1
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +16 -0
- 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 +21 -10
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1437 -54
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +23 -23
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +308 -10
- data/lib/insika/studio/nav_icons.rb +19 -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 +62 -278
- data/lib/insika/studio/views/agents.erb +71 -54
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- 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 +6 -3
- 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 +106 -66
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +15 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +233 -124
- data/lib/insika/studio/views/settings.erb +41 -1
- data/lib/insika/studio/views/skills.erb +1 -2
- 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 +24 -10
- data/lib/insika/task_store.rb +21 -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 +38 -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 +58 -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/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +47 -11
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +89 -0
- 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/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +49 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +472 -13
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +149 -3
- metadata +181 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Studio/CLI-facing adapter over Insika::Demo::Seeder — the one path both
|
|
8
|
+
# `insika demo:seed` and the Studio's "Seed demo data" button use (same
|
|
9
|
+
# discipline as RunDistillation: one object, two front doors).
|
|
10
|
+
class SeedDemoData
|
|
11
|
+
def initialize(seeder:, event_stream:)
|
|
12
|
+
@seeder = seeder
|
|
13
|
+
@event_stream = event_stream
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# payload: { force: bool } -> the seeder's result Hash.
|
|
17
|
+
def call(command)
|
|
18
|
+
force = Coercion.truthy?(command.payload[:force] || command.payload["force"])
|
|
19
|
+
result = @seeder.seed!(force: force)
|
|
20
|
+
if result[:seeded]
|
|
21
|
+
@event_stream.emit(Insika::Event.new(
|
|
22
|
+
type: :demo_data_seeded,
|
|
23
|
+
data: { agent: result[:agent], counts: result[:counts] },
|
|
24
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
25
|
+
))
|
|
26
|
+
end
|
|
27
|
+
result
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -20,12 +20,22 @@ module Insika
|
|
|
20
20
|
# nil = no dedup (every surface that does not send an `event_id`, which is all
|
|
21
21
|
# of them today), and a caller that cannot supply a stable id gets
|
|
22
22
|
# at-least-once turns rather than a content hash pretending to be dedup.
|
|
23
|
-
|
|
23
|
+
#
|
|
24
|
+
# `contact_store`/`followup_store` : the contact-state hook —
|
|
25
|
+
# a customer message reopens the contact cell and a cancellation keyword
|
|
26
|
+
# revokes it + falls the pending records. nil = the hook is off (parity).
|
|
27
|
+
# `store` is the SHARED backend the two stores ride — the keyword revoke
|
|
28
|
+
# commits in ONE transaction (D2). nil = best-effort separate writes.
|
|
29
|
+
def initialize(profiles:, session_store:, task_store:, executor:, inbound_log: nil,
|
|
30
|
+
contact_store: nil, followup_store: nil, store: nil)
|
|
24
31
|
@profiles = ProfileSource.coerce(profiles)
|
|
25
32
|
@session_store = session_store
|
|
26
33
|
@task_store = task_store
|
|
27
34
|
@executor = executor
|
|
28
35
|
@inbound_log = inbound_log
|
|
36
|
+
@contact_store = contact_store
|
|
37
|
+
@followup_store = followup_store
|
|
38
|
+
@store = store
|
|
29
39
|
end
|
|
30
40
|
|
|
31
41
|
def call(command)
|
|
@@ -37,8 +47,15 @@ module Insika
|
|
|
37
47
|
profile = @profiles[agent] ||
|
|
38
48
|
(raise Insika::NotFoundError, "agent '#{agent}' not configured")
|
|
39
49
|
|
|
50
|
+
# A turn is text OR media. The media half (WS9) is what a voice note
|
|
51
|
+
# with no caption looks like on the wire — `{ parts: [{type: "audio",
|
|
52
|
+
# url: …}] }` and nothing else — and demanding a message here made the
|
|
53
|
+
# anchor use case unreachable end to end: the audio becomes the message
|
|
54
|
+
# at the :media stage, one step later.
|
|
40
55
|
message = p[:message]
|
|
41
|
-
|
|
56
|
+
if message.to_s.strip.empty? && !media?(p[:parts])
|
|
57
|
+
raise Insika::ValidationError, "message is required and non-empty (or a media part)"
|
|
58
|
+
end
|
|
42
59
|
|
|
43
60
|
# session_id XOR history (both -> error; neither -> one-shot).
|
|
44
61
|
if p[:session_id] && p[:history]
|
|
@@ -50,7 +67,14 @@ module Insika
|
|
|
50
67
|
# is what a turn has always meant. Refused here rather than downstream: a
|
|
51
68
|
# typo'd origin would read as absent, and a marker that silently means
|
|
52
69
|
# "unmarked" is worse than none — it looks like the filtering is on.
|
|
53
|
-
Insika::MessageOrigin.parse!(p[:origin])
|
|
70
|
+
origin = Insika::MessageOrigin.parse!(p[:origin])
|
|
71
|
+
# `scheduled` is ENGINE-RESERVED — the FollowupEngine's
|
|
72
|
+
# synthetic turn stamps it, and the edge must not let a consumer
|
|
73
|
+
# impersonate the engine's kick (a spoofed follow-up is the spam bug).
|
|
74
|
+
if origin == Insika::MessageOrigin::SCHEDULED
|
|
75
|
+
raise Insika::ValidationError,
|
|
76
|
+
"origin 'scheduled' is engine-reserved: it is stamped by the follow-up engine only"
|
|
77
|
+
end
|
|
54
78
|
if p[:session_id]
|
|
55
79
|
@session_store.find(p[:session_id]) ||
|
|
56
80
|
(raise Insika::NotFoundError, "session '#{p[:session_id]}' not found")
|
|
@@ -66,6 +90,14 @@ module Insika
|
|
|
66
90
|
return { task_id: prior, duplicate: true }
|
|
67
91
|
end
|
|
68
92
|
|
|
93
|
+
# the contact-state hook — the ONLY path that sees every
|
|
94
|
+
# customer message. A real customer message reopens the contact cell; a
|
|
95
|
+
# cancellation keyword revokes it and falls the pending records in ONE
|
|
96
|
+
# transaction. Runs AFTER validation and dedup: a refused or duplicated
|
|
97
|
+
# message must not touch contact state. Nil-safe and policy-gated: no
|
|
98
|
+
# profile declaration = the hook is off (parity).
|
|
99
|
+
touch_contact(p, profile, command, origin)
|
|
100
|
+
|
|
69
101
|
result = start_turn(command, p, profile)
|
|
70
102
|
@inbound_log.record(key, result[:task_id]) if key
|
|
71
103
|
result
|
|
@@ -73,6 +105,41 @@ module Insika
|
|
|
73
105
|
|
|
74
106
|
private
|
|
75
107
|
|
|
108
|
+
# the contact bookkeeping of a customer message. The
|
|
109
|
+
# keyword cast IS a revocation: the customer just said the shut-off
|
|
110
|
+
# words — the pending records must fall with the state (D2, ONE
|
|
111
|
+
# transaction). The reset-on-origin list: only an origin that is
|
|
112
|
+
# nil/customer acts — an engine/operator-composed line (even one QUOTING
|
|
113
|
+
# the customer's words) never touches contact state. Nil collaborators
|
|
114
|
+
# or no profile declaration = the hook is a no-op (byte-parity).
|
|
115
|
+
def touch_contact(p, profile, command, origin)
|
|
116
|
+
return unless @contact_store
|
|
117
|
+
return unless origin.nil? || origin == Insika::MessageOrigin::CUSTOMER
|
|
118
|
+
|
|
119
|
+
policy = Insika::FollowupPolicy.parse(profile.followup)
|
|
120
|
+
return unless policy
|
|
121
|
+
|
|
122
|
+
customer = p[:customer]
|
|
123
|
+
return if customer.to_s.empty?
|
|
124
|
+
|
|
125
|
+
keyword = policy.match_keyword(p[:message].to_s)
|
|
126
|
+
if keyword
|
|
127
|
+
# one transaction on the shared backend: the revoke and the pending
|
|
128
|
+
# fall commit together — a half-cancelled opt-out is the spam bug.
|
|
129
|
+
if @store
|
|
130
|
+
@store.transaction do
|
|
131
|
+
@contact_store.set_revoked(tenant: command.meta[:tenant], customer: customer)
|
|
132
|
+
@followup_store&.cancel_pending_for(tenant: command.meta[:tenant], customer: customer)
|
|
133
|
+
end
|
|
134
|
+
else
|
|
135
|
+
@contact_store.set_revoked(tenant: command.meta[:tenant], customer: customer)
|
|
136
|
+
@followup_store&.cancel_pending_for(tenant: command.meta[:tenant], customer: customer)
|
|
137
|
+
end
|
|
138
|
+
else
|
|
139
|
+
@contact_store.set_granted(tenant: command.meta[:tenant], customer: customer)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
76
143
|
# The turn (or the verdict that this message joined someone else's).
|
|
77
144
|
def start_turn(command, p, profile)
|
|
78
145
|
message = p[:message]
|
|
@@ -83,7 +150,10 @@ module Insika
|
|
|
83
150
|
# boot). Only offered on a surface that can report the verdict back —
|
|
84
151
|
# coalescing a caller that cannot hear `merged` makes it deliver the
|
|
85
152
|
# same answer twice.
|
|
86
|
-
|
|
153
|
+
# A message carrying MEDIA never joins another turn: `collect`/`steer`
|
|
154
|
+
# move TEXT into a task that is already at the door, and its parts would
|
|
155
|
+
# be left behind — the customer's photo would silently not exist.
|
|
156
|
+
if coalescable?(command) && !media?(p[:parts])
|
|
87
157
|
if (joined = @executor.collect_into_pending(p[:session_id], message, profile: profile))
|
|
88
158
|
return { task_id: joined, merged: true }
|
|
89
159
|
end
|
|
@@ -101,16 +171,41 @@ module Insika
|
|
|
101
171
|
# command.to_h persists the entire Command in the Task;
|
|
102
172
|
# ResumeTask re-reads payload.message from there.
|
|
103
173
|
task = @task_store.create(command: command.to_h, session_id: p[:session_id])
|
|
174
|
+
# the channel clock starts HERE — the 202-owning request
|
|
175
|
+
# is accepted, before the SessionActor FIFO and the debounce window.
|
|
176
|
+
# `first_balloon_ms` is the wait the customer feels, so t0 is not the
|
|
177
|
+
# moment the turn finally runs; the same object travels with the turn
|
|
178
|
+
# and `:first_balloon` closes the window at the outbox flush.
|
|
104
179
|
# `interrupt` mode: the turn in flight is now answering the wrong
|
|
105
180
|
# question, so it is abandoned at its next boundary. This message keeps its OWN
|
|
106
181
|
# task and its own reply (that is why it needs no verdict and no surface gate), and
|
|
107
182
|
# the cancel is posted after `create` so the event can name what replaced what.
|
|
108
183
|
# No-op in every other mode.
|
|
109
184
|
@executor.interrupt_running(p[:session_id], profile: profile, replaced_by: task.id)
|
|
110
|
-
@executor.spawn_in_session(task, profile: profile
|
|
185
|
+
@executor.spawn_in_session(task, profile: profile,
|
|
186
|
+
timing: channel_inbound_timing(command))
|
|
111
187
|
{ task_id: task.id }
|
|
112
188
|
end
|
|
113
189
|
|
|
190
|
+
# allocate the channel clock at 202 acceptance and stamp
|
|
191
|
+
# `:inbound` — the window's start. `breakdown: false` when INSIKA_TURN_TIMING
|
|
192
|
+
# is off, so a channel turn measures ONLY first_balloon_ms (H-latência never
|
|
193
|
+
# depends on the flag). nil for every non-channel transport: no clock to start.
|
|
194
|
+
def channel_inbound_timing(command)
|
|
195
|
+
return nil unless command.meta[:transport].to_s.start_with?("channel:")
|
|
196
|
+
|
|
197
|
+
timing = Insika::TurnTiming.new(breakdown: Insika::TurnTiming.enabled?)
|
|
198
|
+
timing.mark(:inbound)
|
|
199
|
+
timing
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Does the payload carry a part the engine will turn into the turn's
|
|
203
|
+
# substance — audio (transcribed into the message) or an image (attached
|
|
204
|
+
# to the ask)? A text part is not media: it is the message, spelled long.
|
|
205
|
+
def media?(parts)
|
|
206
|
+
Insika::Media.parts(parts).any? { |p| p.audio? || p.image? }
|
|
207
|
+
end
|
|
208
|
+
|
|
114
209
|
def coalescable?(command)
|
|
115
210
|
transport = command.meta[:transport]
|
|
116
211
|
COALESCABLE_TRANSPORTS.include?(transport) || transport.to_s.start_with?("channel:")
|
|
@@ -133,7 +228,9 @@ module Insika
|
|
|
133
228
|
session_id: payload[:session_id] || payload["session_id"],
|
|
134
229
|
history: payload[:history] || payload["history"],
|
|
135
230
|
origin: payload[:origin] || payload["origin"],
|
|
136
|
-
event_id: payload[:event_id] || payload["event_id"]
|
|
231
|
+
event_id: payload[:event_id] || payload["event_id"],
|
|
232
|
+
parts: payload[:parts] || payload["parts"],
|
|
233
|
+
customer: payload[:customer] || payload["customer"]
|
|
137
234
|
}
|
|
138
235
|
end
|
|
139
236
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Commands
|
|
5
|
+
# The CONVERSATION footprint of a session, purged (WS8/LGPD). Shared by
|
|
6
|
+
# `forget_customer` and `delete_tenant_data` because "erase this person"
|
|
7
|
+
# and "erase this tenant" differ only in WHICH sessions they name — what a
|
|
8
|
+
# session leaves behind is the same list, and a second copy of that list is
|
|
9
|
+
# a second thing to forget to update.
|
|
10
|
+
#
|
|
11
|
+
# Deleting the session record alone is NOT erasure: the customer's own text
|
|
12
|
+
# lives in the task's persisted command payload, the whole transcript lives
|
|
13
|
+
# in the turn's checkpoints, and the answer as it was handed to the channel
|
|
14
|
+
# lives in the outbox record's payload. All four go together or none of them
|
|
15
|
+
# counts.
|
|
16
|
+
#
|
|
17
|
+
# A task is deleted whatever its status — this is a deletion order, not the
|
|
18
|
+
# retention sweep (which spares live tasks on purpose). The stores it needs
|
|
19
|
+
# beyond the session are optional (deployment components): a graph without
|
|
20
|
+
# them purges what it has and reports zero for the rest.
|
|
21
|
+
module SessionPurge
|
|
22
|
+
# ids: the session ids to erase. -> { tasks:, checkpoints:, deliveries:, pairs: }
|
|
23
|
+
def purge_sessions(ids)
|
|
24
|
+
ids = Array(ids).map(&:to_s)
|
|
25
|
+
return { tasks: 0, checkpoints: 0, deliveries: 0, pairs: 0 } if ids.empty?
|
|
26
|
+
|
|
27
|
+
tasks, checkpoints, model_visible = purge_tasks_of(ids)
|
|
28
|
+
deliveries = @outbox_store ? @outbox_store.purge_sessions(ids) : 0
|
|
29
|
+
pairs = @shadow_pairs ? @shadow_pairs.purge_sessions(ids) : 0
|
|
30
|
+
ids.each do |id|
|
|
31
|
+
@tool_trace_store&.clear(id)
|
|
32
|
+
@context_trace_store&.clear(id)
|
|
33
|
+
@session_store.delete(id)
|
|
34
|
+
end
|
|
35
|
+
{ tasks: tasks, checkpoints: checkpoints, model_visible: model_visible,
|
|
36
|
+
deliveries: deliveries, pairs: pairs }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# -> [tasks removed, checkpoint records removed, model-visible records removed].
|
|
42
|
+
# the model-visible traces are transcripts — they die next to
|
|
43
|
+
# their checkpoints (the same single list, so "erase this person" cannot
|
|
44
|
+
# forget one half). The id list is SNAPSHOTTED (`to_a`) before the deletes:
|
|
45
|
+
# `each_id` enumerates the backend's keys lazily, and deleting under it
|
|
46
|
+
# would skip records.
|
|
47
|
+
def purge_tasks_of(ids)
|
|
48
|
+
return [0, 0, 0] unless @task_store
|
|
49
|
+
|
|
50
|
+
wanted = ids.each_with_object({}) { |id, acc| acc[id] = true }
|
|
51
|
+
tasks = 0
|
|
52
|
+
checkpoints = 0
|
|
53
|
+
model_visible = 0
|
|
54
|
+
@task_store.each_id.to_a.each do |task_id|
|
|
55
|
+
task = @task_store.find(task_id)
|
|
56
|
+
next unless task && wanted[task.session_id.to_s]
|
|
57
|
+
|
|
58
|
+
checkpoints += @checkpoint_store ? @checkpoint_store.purge(task_id) : 0
|
|
59
|
+
model_visible += @model_visible_trace_store ? @model_visible_trace_store.purge(task_id) : 0
|
|
60
|
+
@task_store.delete(task_id)
|
|
61
|
+
tasks += 1
|
|
62
|
+
end
|
|
63
|
+
[tasks, checkpoints, model_visible]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -8,17 +8,20 @@ module Insika
|
|
|
8
8
|
# (transport/command/url/enabled + `env` credentials) in the McpStore. The
|
|
9
9
|
# credentials are sentinel-aware per key (__OCULTO__ preserves; "" clears;
|
|
10
10
|
# a new string replaces). Returns the MASKED record (env never comes back in
|
|
11
|
-
# plaintext).
|
|
12
|
-
#
|
|
11
|
+
# plaintext). Evicts any memoized client for this name (mcp_registry is
|
|
12
|
+
# optional so a bare CLI/store-only caller need not wire one) so an edited
|
|
13
|
+
# command/url/env takes effect on the instance's next call — no restart.
|
|
13
14
|
class UpsertMcp
|
|
14
|
-
def initialize(mcp_store:, event_stream:)
|
|
15
|
+
def initialize(mcp_store:, event_stream:, mcp_registry: nil)
|
|
15
16
|
@mcp_store = mcp_store
|
|
16
17
|
@event_stream = event_stream
|
|
18
|
+
@mcp_registry = mcp_registry
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
def call(command)
|
|
20
22
|
p = AgentPayload.symbolize(command.payload)
|
|
21
23
|
masked = @mcp_store.upsert(p) # validates `name`; returns masked
|
|
24
|
+
@mcp_registry&.evict(masked["name"])
|
|
22
25
|
|
|
23
26
|
@event_stream.emit(Insika::Event.new(
|
|
24
27
|
type: :mcp_upserted,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: writes a concept (complete concept markdown) into the
|
|
8
|
+
# KnowledgeStore. Validates the frontmatter (a `name` is required) before
|
|
9
|
+
# writing — same discipline as `WriteSkill`. The STORE POSITION (the
|
|
10
|
+
# `agent`/`name`/`tenant` triple) is the identity, exactly like a skill:
|
|
11
|
+
# an operator editing the raw markdown may leave the frontmatter `name:`
|
|
12
|
+
# unchanged even while promoting `provenance: observed` to `policy` —
|
|
13
|
+
# that promotion IS this command, no separate "promote" action needed.
|
|
14
|
+
# -> { name, agent, tenant, updated_at }.
|
|
15
|
+
class WriteConcept
|
|
16
|
+
def initialize(knowledge_store:, event_stream:)
|
|
17
|
+
@knowledge_store = knowledge_store
|
|
18
|
+
@event_stream = event_stream
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(command)
|
|
22
|
+
p = AgentPayload.symbolize(command.payload)
|
|
23
|
+
name = AgentPayload.presence(p[:name])
|
|
24
|
+
agent = AgentPayload.presence(p[:agent])
|
|
25
|
+
tenant = AgentPayload.presence(p[:tenant])
|
|
26
|
+
content = p[:content].to_s
|
|
27
|
+
raise Insika::ValidationError, "name is required" if name.nil?
|
|
28
|
+
raise Insika::ValidationError, "agent is required" if agent.nil?
|
|
29
|
+
|
|
30
|
+
parsed = validate_frontmatter!(content)
|
|
31
|
+
rec = @knowledge_store.write(agent, name, content, tenant: tenant)
|
|
32
|
+
emit(parsed[:type], name, agent)
|
|
33
|
+
{ name: name, agent: agent, tenant: tenant, updated_at: rec["updated_at"] }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def emit(type, name, agent)
|
|
39
|
+
@event_stream.emit(Insika::Event.new(
|
|
40
|
+
type: :knowledge_learned, data: { name: name, type: type, agent: agent },
|
|
41
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
42
|
+
))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Mirrors WriteSkill's frontmatter guard: without a parseable `name`,
|
|
46
|
+
# the concept would be unreachable by name once written (the Studio
|
|
47
|
+
# list keys on the store position, but the editor round-trips the
|
|
48
|
+
# content) — fail early, in the Command.
|
|
49
|
+
def validate_frontmatter!(content)
|
|
50
|
+
parsed = Insika::Knowledge::Concept.parse(content)
|
|
51
|
+
raise Insika::ValidationError, "concept missing YAML frontmatter (--- ... ---) or a `name`" if parsed.nil?
|
|
52
|
+
|
|
53
|
+
parsed
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# the durable CONTACT STATE cell per (tenant, customer)— ONE
|
|
7
|
+
# derived cell, never a transition log (D2): `granted | revoked |
|
|
8
|
+
# unavailable`, the sends-without-reply counter and the last outbound
|
|
9
|
+
# timestamp. Dumb domain store: it holds no policy and no follow-up records
|
|
10
|
+
# (the firer and the inbound hook own those transformations).
|
|
11
|
+
#
|
|
12
|
+
# Invariants (the firer enforces them, the store only records):
|
|
13
|
+
# · only `granted` may be messaged; absent = never messaged (:consent block);
|
|
14
|
+
# · `revoked` is immediate and permanent until the customer speaks;
|
|
15
|
+
# · `unavailable` means silence ≠ refusal — set when sends_without_reply
|
|
16
|
+
# reaches the policy's ceiling; further fires block until the customer
|
|
17
|
+
# speaks; any customer message reopens (sets granted + zeroes the counter).
|
|
18
|
+
#
|
|
19
|
+
# The customer identifier is the SAME string the message contract carries
|
|
20
|
+
# (`customer:` on /v1/messages, WS8).
|
|
21
|
+
class ContactStore
|
|
22
|
+
SCOPE = "contacts"
|
|
23
|
+
STATES = %w[granted revoked unavailable].freeze
|
|
24
|
+
|
|
25
|
+
Cell = Data.define(:state, :sends_without_reply, :last_outbound_at, :updated_at)
|
|
26
|
+
|
|
27
|
+
def initialize(store:)
|
|
28
|
+
@store = store
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# -> [Cell] | nil (absent = never messaged — the :consent block). nil
|
|
32
|
+
# customer -> nil (an untagged conversation has no cell).
|
|
33
|
+
def get(tenant:, customer:)
|
|
34
|
+
return nil if customer.to_s.empty?
|
|
35
|
+
|
|
36
|
+
record = @store.get(SCOPE, key(tenant, customer))
|
|
37
|
+
record && to_cell(record)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# -> { "<tenant>:<customer>" => raw record } — the whole scope, for the
|
|
41
|
+
# doctor's contact summary and the Studio (read-only folds; the mutations
|
|
42
|
+
# go through the commands, D10).
|
|
43
|
+
def cells
|
|
44
|
+
@store.list(SCOPE).each_with_object({}) do |k, acc|
|
|
45
|
+
record = @store.get(SCOPE, k)
|
|
46
|
+
acc[k] = record if record
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The ONLY writers. Each is a read-check-write on the cell (#transaction —
|
|
51
|
+
# two processes flipping the state in the same second serialize).
|
|
52
|
+
|
|
53
|
+
# Any customer message reopens the conversation: `granted` + the counter
|
|
54
|
+
# reset. The consent record itself (D2/D7) is the `schedule` tool call.
|
|
55
|
+
def set_granted(tenant:, customer:, now: Time.now.utc)
|
|
56
|
+
write(tenant, customer, now) do |record|
|
|
57
|
+
record["state"] = "granted"
|
|
58
|
+
record["sends_without_reply"] = 0
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# D7: the schedule tool's consent write. The customer agreeing
|
|
63
|
+
# in-conversation IS the consent — but ONLY a customer message reopens
|
|
64
|
+
# (D2): this NEVER lifts `:unavailable` and NEVER resets
|
|
65
|
+
# sends_without_reply, so a re-booking inside the scheduled turn cannot
|
|
66
|
+
# clear the silence protection. Creates the cell when absent (the first
|
|
67
|
+
# consent). Raises Insika::ValidationError on `:revoked` — an opt-out is
|
|
68
|
+
# permanent; the caller refuses.
|
|
69
|
+
def consent(tenant:, customer:, now: Time.now.utc)
|
|
70
|
+
raise Insika::ValidationError, "customer is required" if customer.to_s.empty?
|
|
71
|
+
|
|
72
|
+
@store.transaction do
|
|
73
|
+
record = @store.get(SCOPE, key(tenant, customer)) ||
|
|
74
|
+
{ "state" => "granted", "sends_without_reply" => 0,
|
|
75
|
+
"last_outbound_at" => nil, "updated_at" => nil }
|
|
76
|
+
if record["state"] == "revoked"
|
|
77
|
+
raise Insika::ValidationError,
|
|
78
|
+
"this customer opted out — you cannot schedule a follow-up"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
record["state"] ||= "granted"
|
|
82
|
+
record["updated_at"] = now.iso8601
|
|
83
|
+
@store.set(SCOPE, key(tenant, customer), record)
|
|
84
|
+
to_cell(record)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Keyword / channel opt-out / operator: immediate and permanent until the
|
|
89
|
+
# customer speaks. Nothing auto-revokes.
|
|
90
|
+
def set_revoked(tenant:, customer:, now: Time.now.utc)
|
|
91
|
+
write(tenant, customer, now) do |record|
|
|
92
|
+
record["state"] = "revoked"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Silence reached the policy's ceiling (the firer counts sends without a
|
|
97
|
+
# reply); further fires block until the customer speaks.
|
|
98
|
+
def mark_unavailable(tenant:, customer:, now: Time.now.utc)
|
|
99
|
+
write(tenant, customer, now) do |record|
|
|
100
|
+
record["state"] = "unavailable"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The firer's call: sends_without_reply += 1, last_outbound_at = now.
|
|
105
|
+
# Creates the cell when absent (granted — the firer checks the GO before
|
|
106
|
+
# bumping, so a bump only happens after the consent gate).
|
|
107
|
+
def bump_outbound(tenant:, customer:, now: Time.now.utc)
|
|
108
|
+
write(tenant, customer, now) do |record|
|
|
109
|
+
record["state"] ||= "granted"
|
|
110
|
+
record["sends_without_reply"] = record["sends_without_reply"].to_i + 1
|
|
111
|
+
record["last_outbound_at"] = now.iso8601
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Purge paths (C11 — the LGPD footprint): one cell; a whole tenant's
|
|
116
|
+
# (prefix scan); age-based. All nil-safe.
|
|
117
|
+
|
|
118
|
+
# -> true | false (did the cell exist?)
|
|
119
|
+
def delete(tenant:, customer:)
|
|
120
|
+
return false if customer.to_s.empty?
|
|
121
|
+
|
|
122
|
+
@store.delete(SCOPE, key(tenant, customer))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# -> count removed.
|
|
126
|
+
def purge(tenant:)
|
|
127
|
+
prefix = "#{tenant_id(tenant)}:"
|
|
128
|
+
keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
|
|
129
|
+
keys.each { |k| @store.delete(SCOPE, k) }
|
|
130
|
+
keys.size
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Cells untouched past the cutoff (WS8 retention). -> count removed.
|
|
134
|
+
def delete_older_than(time)
|
|
135
|
+
cutoff = time.utc.iso8601
|
|
136
|
+
removed = 0
|
|
137
|
+
@store.list(SCOPE).each do |k|
|
|
138
|
+
record = @store.get(SCOPE, k)
|
|
139
|
+
next unless record && record["updated_at"].to_s < cutoff
|
|
140
|
+
|
|
141
|
+
@store.delete(SCOPE, k)
|
|
142
|
+
removed += 1
|
|
143
|
+
end
|
|
144
|
+
removed
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# One cell per (tenant, customer); blank tenant -> the literal "platform"
|
|
150
|
+
# (outcome_store.rb's rule — contact, follow-up and outcome keys share one
|
|
151
|
+
# tenant segment so the purge prefix scans line up).
|
|
152
|
+
def key(tenant, customer)
|
|
153
|
+
"#{tenant_id(tenant)}:#{customer}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def tenant_id(tenant)
|
|
157
|
+
t = tenant.to_s
|
|
158
|
+
t.empty? ? "platform" : t
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Read-check-write inside the backend transaction (the budget_ledger.rb:38
|
|
162
|
+
# discipline): two writers racing the same cell serialize on the backend
|
|
163
|
+
# lock and the loser re-reads.
|
|
164
|
+
def write(tenant, customer, now, &block)
|
|
165
|
+
raise Insika::ValidationError, "customer is required" if customer.to_s.empty?
|
|
166
|
+
|
|
167
|
+
@store.transaction do
|
|
168
|
+
record = @store.get(SCOPE, key(tenant, customer)) ||
|
|
169
|
+
{ "state" => "granted", "sends_without_reply" => 0,
|
|
170
|
+
"last_outbound_at" => nil, "updated_at" => nil }
|
|
171
|
+
block.call(record)
|
|
172
|
+
record["updated_at"] = now.iso8601
|
|
173
|
+
@store.set(SCOPE, key(tenant, customer), record)
|
|
174
|
+
to_cell(record)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def to_cell(record)
|
|
179
|
+
Cell.new(state: record["state"], sends_without_reply: record["sends_without_reply"].to_i,
|
|
180
|
+
last_outbound_at: record["last_outbound_at"], updated_at: record["updated_at"])
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -66,7 +66,14 @@ module Insika
|
|
|
66
66
|
|
|
67
67
|
fragments = []
|
|
68
68
|
tasks.each do |provider, child|
|
|
69
|
-
|
|
69
|
+
# stamp the layer at PRODUCTION from the PROVIDER's
|
|
70
|
+
# declaration — authoritative, so a fragment that arrives pre-stamped
|
|
71
|
+
# cannot sneak above the boundary (a :volatile provider emitting a
|
|
72
|
+
# fragment with layer :identity would otherwise bypass the doctor's
|
|
73
|
+
# class-level check). A custom provider that never learned the contract
|
|
74
|
+
# leaves :volatile.
|
|
75
|
+
layer = provider.respond_to?(:layer) ? provider.layer : :volatile
|
|
76
|
+
Array(child.wait).each { |f| fragments << f.with(layer: layer) }
|
|
70
77
|
rescue StandardError => e # Async::TimeoutError is a StandardError; Async::Stop is NOT (propagates)
|
|
71
78
|
handle_provider_failure(provider, e, request)
|
|
72
79
|
end
|
|
@@ -135,10 +142,14 @@ module Insika
|
|
|
135
142
|
[survivors, evicted_sources]
|
|
136
143
|
end
|
|
137
144
|
|
|
138
|
-
# Step 6: assembly in DETERMINISTIC canonical order.
|
|
145
|
+
# Step 6: assembly in DETERMINISTIC canonical order. The
|
|
146
|
+
# identity-first partition — nothing volatile renders above the cache
|
|
147
|
+
# boundary. Each partition keeps the existing canonical sort; the partition
|
|
148
|
+
# is by LAYER only.
|
|
139
149
|
def assemble(fragments, cap, evicted)
|
|
140
150
|
system_frags = fragments.select { |f| f.placement == :system }
|
|
141
|
-
|
|
151
|
+
identity, volatile = system_frags.partition { |f| (f.layer || :volatile) == :identity }
|
|
152
|
+
system_frags = sort_canonical(identity) + sort_canonical(volatile)
|
|
142
153
|
history_frags = fragments.select { |f| f.placement == :history } # production order (chronological)
|
|
143
154
|
tool_frags = fragments.select { |f| f.placement == :tool_context }
|
|
144
155
|
|
|
@@ -153,6 +164,13 @@ module Insika
|
|
|
153
164
|
)
|
|
154
165
|
end
|
|
155
166
|
|
|
167
|
+
# The canonical system sort (priority DESC, source ASC, production index) —
|
|
168
|
+
# applied WITHIN each layer partition, so the prior order survives inside
|
|
169
|
+
# it and the boundary is the only thing that moved.
|
|
170
|
+
def sort_canonical(frags)
|
|
171
|
+
frags.sort_by.with_index { |f, i| [-f.priority, f.source.to_s, i] }
|
|
172
|
+
end
|
|
173
|
+
|
|
156
174
|
# :provider_warning. The Builder does not know task_id/seq (correlation is
|
|
157
175
|
# the Executor's job) — emits with what it has; Event#to_h does meta.compact.
|
|
158
176
|
def emit_warning(provider_id, message, request)
|
|
@@ -8,6 +8,10 @@ module Insika
|
|
|
8
8
|
# tokens: Integer | nil; estimated by the Builder when nil
|
|
9
9
|
# source: String — provider id (audit)
|
|
10
10
|
# pinned: true -> uncuttable in the budget (e.g. identity)
|
|
11
|
+
# layer: :identity | :volatile | nil. Stamped by the Builder
|
|
12
|
+
# at production from the provider's declaration; nil (a fragment
|
|
13
|
+
# built outside the Builder) reads as :volatile everywhere it is
|
|
14
|
+
# consumed — parity, never a crash.
|
|
11
15
|
# labels: [{ "name" =>, "reason" => }] — WHAT this fragment carries and WHY,
|
|
12
16
|
# as ids. Content-FREE by contract, so the context trace can report
|
|
13
17
|
# which skills a turn injected without storing a byte of the bodies.
|
|
@@ -22,12 +26,12 @@ module Insika
|
|
|
22
26
|
# written to the context trace and to events as JSON: the round-trip
|
|
23
27
|
# is then the identity, and no reader has to defend against both.
|
|
24
28
|
ContextFragment = Data.define(:content, :placement, :priority, :tokens,
|
|
25
|
-
:source, :pinned, :labels) do
|
|
29
|
+
:source, :pinned, :labels, :layer) do
|
|
26
30
|
def self.build(content:, placement:, source:, priority: 50, tokens: nil,
|
|
27
|
-
pinned: false, labels: [])
|
|
31
|
+
pinned: false, labels: [], layer: nil)
|
|
28
32
|
new(content: content, placement: placement, priority: priority,
|
|
29
33
|
tokens: tokens, source: source, pinned: pinned,
|
|
30
|
-
labels: Array(labels).map { |l| label(l) })
|
|
34
|
+
labels: Array(labels).map { |l| label(l) }, layer: layer)
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
# A bare String is still a valid label (a provider that has an id but no reason
|
|
@@ -20,8 +20,13 @@ module Insika
|
|
|
20
20
|
PROMPT_REF = 90 # Prompt Catalog guardrails/refs (Prompt) — pinned
|
|
21
21
|
SKILL_BODY = 85 # <active_skill> trigger-matched body (SkillTrigger)
|
|
22
22
|
SKILL = 80 # <available_skills> level 1 (Skill)
|
|
23
|
+
KNOWLEDGE = 77 # <knowledge> top-K learned concepts (Knowledge) —
|
|
24
|
+
# below curated skills, above a single conversation's memory
|
|
23
25
|
MEMORY = 75 # <memory> read path (Memory)
|
|
24
26
|
TOOL_SEARCH = 70 # <available_tools> level 1 (ToolSearch)
|
|
27
|
+
BRIEFING = 65 # <briefing> session working state (Briefing) — D5:
|
|
28
|
+
# below every identity/skill/memory block (never breaks the
|
|
29
|
+
# pinned prefix), above the turn's own <request_context>.
|
|
25
30
|
HISTORY_MAX = 79 # history ceiling by recency (Session)
|
|
26
31
|
HISTORY_BASE = 60 # history base; +idx up to the ceiling (Session)
|
|
27
32
|
REQUEST = 40 # <request_context> — turn injection, the most cuttable
|
|
@@ -9,11 +9,25 @@ module Insika
|
|
|
9
9
|
def required? = false # true -> failure aborts the turn
|
|
10
10
|
def enabled_for?(_profile) = true
|
|
11
11
|
def call(_request) = [] # -> [ContextFragment]; may do IO
|
|
12
|
+
# which cache layer the output belongs to.
|
|
13
|
+
# :identity -> changes only on deploy/config edit (the cacheable prefix);
|
|
14
|
+
# :volatile -> may change per turn (the Builder renders identity first).
|
|
15
|
+
# :volatile is the conservative default — nothing gets pinned by accident.
|
|
16
|
+
def layer = :volatile
|
|
12
17
|
end
|
|
13
18
|
|
|
14
19
|
# Input for the provider contract.
|
|
15
|
-
# session:
|
|
16
|
-
# checkpoint:
|
|
20
|
+
# session: SessionStore::Session | nil
|
|
21
|
+
# checkpoint: Checkpoint | nil (present on ResumeTask — history comes from it)
|
|
22
|
+
# memory_scope: the CUSTOMER-scoped memory cell (WS8): "[tenant:]customer"
|
|
23
|
+
# when the request carries a customer, else nil (the providers
|
|
24
|
+
# fall back to tenant || session). Kept separate from `tenant`
|
|
25
|
+
# (the <request_context> merchant label) on purpose.
|
|
17
26
|
ContextRequest = Data.define(:session, :message, :profile, :tenant, :vars,
|
|
18
|
-
:checkpoint)
|
|
27
|
+
:checkpoint, :memory_scope) do
|
|
28
|
+
def initialize(session: nil, message: nil, profile: nil, tenant: nil, vars: {},
|
|
29
|
+
checkpoint: nil, memory_scope: nil)
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
end
|
|
19
33
|
end
|