insika 0.0.1 → 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 +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +375 -0
- data/docs/SKILLS.md +284 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- 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 +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +112 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +132 -0
- data/lib/insika/executor.rb +1995 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +102 -0
- data/lib/insika/safety/moderator.rb +94 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +220 -0
- data/lib/insika/skill_store.rb +127 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +162 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: enables/disables a skill on N
|
|
8
|
+
# agents at once, adjusting each one's `profile.skills` allowlist — and, with
|
|
9
|
+
# `eager_ids`, which of them keep the body in the prompt on EVERY turn
|
|
10
|
+
# (`profile.skills_eager`). Takes effect on the next dispatch (hot via
|
|
11
|
+
# ProfileSource). -> { name, enabled_for, eager_for, skipped_all, skipped_eager_all }.
|
|
12
|
+
#
|
|
13
|
+
# Both are per-agent decisions about the same skill, which is why they are one
|
|
14
|
+
# command: eagerness lives on the agent precisely BECAUSE the skill is shared, so
|
|
15
|
+
# the screen that says "which agents can load this" is the screen that says "and
|
|
16
|
+
# which of them always have it".
|
|
17
|
+
#
|
|
18
|
+
# Allowlist semantics (AgentProfile): nil = ALL, [] = none,
|
|
19
|
+
# [names] = subset. Important and deliberate consequence:
|
|
20
|
+
# - enabling on an agent with skills=nil: no-op (already has all).
|
|
21
|
+
# - DISABLING on an agent with skills=nil: NOT done here — removing one from
|
|
22
|
+
# "all" would require enumerating the catalog and materializing an explicit
|
|
23
|
+
# allowlist (destructive/surprising). These agents are left intact and
|
|
24
|
+
# go into `skipped_all`. To restrict, use an explicit :set_agent_tools/allowlist
|
|
25
|
+
# first.
|
|
26
|
+
#
|
|
27
|
+
# `skills_eager` is NOT allowlist semantics (nil = NONE, see
|
|
28
|
+
# SkillCatalog#eager_for), so the two fields are adjusted by different rules: nil
|
|
29
|
+
# there is an empty set that can simply be added to, and only the BLANKET `true`
|
|
30
|
+
# has the same "cannot remove one name from all" problem (-> skipped_eager_all).
|
|
31
|
+
class SetSkillAgents
|
|
32
|
+
def initialize(profile_source:, event_stream:)
|
|
33
|
+
@profile_source = profile_source
|
|
34
|
+
@event_stream = event_stream
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def call(command)
|
|
38
|
+
p = AgentPayload.symbolize(command.payload)
|
|
39
|
+
name = AgentPayload.presence(p[:name])
|
|
40
|
+
raise Insika::ValidationError, "name is required" if name.nil?
|
|
41
|
+
raise Insika::ValidationError, "agent_ids must be a list" unless p[:agent_ids].nil? || p[:agent_ids].is_a?(Array)
|
|
42
|
+
raise Insika::ValidationError, "eager_ids must be a list" unless p[:eager_ids].nil? || p[:eager_ids].is_a?(Array)
|
|
43
|
+
|
|
44
|
+
wanted = Array(p[:agent_ids]).map(&:to_s)
|
|
45
|
+
# eager_ids ABSENT means "this form does not manage eagerness" — leave every
|
|
46
|
+
# profile's setting alone. An empty ARRAY means "none of them", which is a real
|
|
47
|
+
# instruction and must be applied. `nil` and `[]` are not the same request.
|
|
48
|
+
eager_wanted = p[:eager_ids].nil? ? nil : Array(p[:eager_ids]).map(&:to_s)
|
|
49
|
+
result = { enabled_for: [], eager_for: [], skipped_all: [], skipped_eager_all: [] }
|
|
50
|
+
|
|
51
|
+
@profile_source.all.each { |profile| apply(profile, name, wanted, eager_wanted, result) }
|
|
52
|
+
|
|
53
|
+
@event_stream.emit(Insika::Event.new(
|
|
54
|
+
type: :skill_agents_set,
|
|
55
|
+
data: { name: name, agent_ids: wanted, eager_ids: eager_wanted,
|
|
56
|
+
skipped_all: result[:skipped_all] }.compact,
|
|
57
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
58
|
+
))
|
|
59
|
+
{ name: name }.merge(result)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def apply(profile, name, wanted, eager_wanted, result)
|
|
65
|
+
want = wanted.include?(profile.id)
|
|
66
|
+
new_skills = next_skills(profile.skills, name, want)
|
|
67
|
+
new_eager = next_eager(profile, name, eager_wanted, result)
|
|
68
|
+
|
|
69
|
+
if new_skills == :skip
|
|
70
|
+
result[:skipped_all] << profile.id
|
|
71
|
+
new_skills = profile.skills
|
|
72
|
+
end
|
|
73
|
+
result[:enabled_for] << profile.id if want
|
|
74
|
+
result[:eager_for] << profile.id if eager_wanted&.include?(profile.id)
|
|
75
|
+
return if new_skills == profile.skills && new_eager == profile.skills_eager
|
|
76
|
+
|
|
77
|
+
@profile_source.put(Insika::AgentProfile.build(
|
|
78
|
+
**profile.to_h.merge(skills: new_skills, skills_eager: new_eager)
|
|
79
|
+
))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# -> new allowlist | :skip (agent with nil that requested disable).
|
|
83
|
+
def next_skills(current, name, want)
|
|
84
|
+
if want
|
|
85
|
+
current.nil? ? nil : (Array(current).map(&:to_s) | [name])
|
|
86
|
+
elsif current.nil?
|
|
87
|
+
:skip
|
|
88
|
+
else
|
|
89
|
+
Array(current).map(&:to_s) - [name]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# -> new skills_eager. nil eager_wanted = not managed by this call.
|
|
94
|
+
def next_eager(profile, name, eager_wanted, result)
|
|
95
|
+
current = profile.skills_eager
|
|
96
|
+
return current if eager_wanted.nil?
|
|
97
|
+
|
|
98
|
+
want = eager_wanted.include?(profile.id)
|
|
99
|
+
# Blanket `true` already includes every skill; removing ONE name from it would
|
|
100
|
+
# mean materializing the whole catalog as a list, the same destructive surprise
|
|
101
|
+
# `skipped_all` exists for.
|
|
102
|
+
if Coercion.truthy?(current)
|
|
103
|
+
result[:skipped_eager_all] << profile.id unless want
|
|
104
|
+
return current
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
list = current.nil? || current == false ? [] : Array(current).map(&:to_s)
|
|
108
|
+
want ? (list | [name]) : (list - [name])
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Commands
|
|
5
|
+
# Turn command: fires a workflow. Reuses the canonical
|
|
6
|
+
# pipeline — only stage 6 varies (Executor). Validates everything
|
|
7
|
+
# synchronously and responds `{task_id:}` immediately. The workflow allowlist is NOT
|
|
8
|
+
# checked here: it is stage-3 enforcement via WorkflowAllowlist
|
|
9
|
+
# -> PolicyDenied -> task :failed.
|
|
10
|
+
class TriggerWorkflow
|
|
11
|
+
ALLOWED_KEYS = %i[workflow agent input session_id].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(profiles:, session_store:, task_store:, executor:, workflow_registry:)
|
|
14
|
+
@profiles = ProfileSource.coerce(profiles)
|
|
15
|
+
@session_store = session_store
|
|
16
|
+
@task_store = task_store
|
|
17
|
+
@executor = executor
|
|
18
|
+
@workflow_registry = workflow_registry
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(command)
|
|
22
|
+
p = normalize(command.payload)
|
|
23
|
+
reject_unknown_keys!(command.payload) # strict validation
|
|
24
|
+
|
|
25
|
+
workflow = p[:workflow].to_s
|
|
26
|
+
raise Insika::ValidationError, "workflow is required" if workflow.empty?
|
|
27
|
+
|
|
28
|
+
agent = p[:agent].to_s
|
|
29
|
+
raise Insika::ValidationError, "agent is required" if agent.empty?
|
|
30
|
+
|
|
31
|
+
profile = @profiles[agent] ||
|
|
32
|
+
(raise Insika::NotFoundError, "agent '#{agent}' not configured")
|
|
33
|
+
|
|
34
|
+
input = p[:input] || {}
|
|
35
|
+
raise Insika::ValidationError, "input must be a Hash" unless input.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
if p[:session_id]
|
|
38
|
+
@session_store.find(p[:session_id]) ||
|
|
39
|
+
(raise Insika::NotFoundError, "session '#{p[:session_id]}' not found")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# existence is validatable without executing — names, NEVER resolve (don't instantiate
|
|
43
|
+
# outside the fiber).
|
|
44
|
+
unless @workflow_registry.names.include?(workflow)
|
|
45
|
+
raise Insika::NotFoundError, "workflow '#{workflow}' not registered"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# I/O by schema: the INPUT is validated SYNCHRONOUSLY —
|
|
49
|
+
# a non-conforming input is a WorkflowSchemaError (< ValidationError -> 422)
|
|
50
|
+
# with NO run created. `definition` reads the schema metadata without
|
|
51
|
+
# resolving the factory (stays out of the fiber). No-op without an input_schema.
|
|
52
|
+
@workflow_registry.definition(workflow).validate_input!(input)
|
|
53
|
+
|
|
54
|
+
task = @task_store.create(command: command.to_h, session_id: p[:session_id])
|
|
55
|
+
@executor.spawn_in_session(task, profile: profile)
|
|
56
|
+
# runId = the run's durable record, which IS the Task (checkpointed +
|
|
57
|
+
# recoverable). Exposed under both names so the workflow consumer has a
|
|
58
|
+
# stable `run_id` while every task surface (GET /v1/tasks/:id, the event
|
|
59
|
+
# stream) keeps working unchanged.
|
|
60
|
+
{ task_id: task.id, run_id: task.id }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def normalize(payload)
|
|
66
|
+
{
|
|
67
|
+
workflow: payload[:workflow] || payload["workflow"],
|
|
68
|
+
agent: payload[:agent] || payload["agent"],
|
|
69
|
+
input: payload[:input] || payload["input"],
|
|
70
|
+
session_id: payload[:session_id] || payload["session_id"]
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reject_unknown_keys!(payload)
|
|
75
|
+
extra = payload.keys.map(&:to_sym) - ALLOWED_KEYS
|
|
76
|
+
raise Insika::ValidationError, "unknown keys in payload: #{extra.join(', ')}" unless extra.empty?
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: edits an existing agent. Merges the
|
|
8
|
+
# patch over the current profile (only the sent fields change) + rebuild + put.
|
|
9
|
+
# Takes effect on the NEXT dispatch (the ProfileSource reads fresh). -> AgentProfile.
|
|
10
|
+
class UpdateAgent
|
|
11
|
+
def initialize(profile_source:, event_stream:)
|
|
12
|
+
@profile_source = profile_source
|
|
13
|
+
@event_stream = event_stream
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(command)
|
|
17
|
+
patch = AgentPayload.attrs(command.payload)
|
|
18
|
+
id = AgentPayload.presence(patch[:id])
|
|
19
|
+
raise Insika::ValidationError, "id is required" if id.nil?
|
|
20
|
+
|
|
21
|
+
existing = @profile_source.fetch(id) ||
|
|
22
|
+
(raise Insika::NotFoundError, "agent '#{id}' not found")
|
|
23
|
+
|
|
24
|
+
# to_h of the current profile brings the correct symbols; patch overwrites only
|
|
25
|
+
# what was sent. id never changes (rename = create+delete, out of scope).
|
|
26
|
+
merged = existing.to_h.merge(patch).merge(id: id)
|
|
27
|
+
profile = Insika::AgentProfile.build(**merged)
|
|
28
|
+
# definition-time cycle + depth check (an update may ADD
|
|
29
|
+
# subagents to an existing agent). Raises SubagentError before persisting.
|
|
30
|
+
validate_subagent_graph!(profile)
|
|
31
|
+
@profile_source.put(profile)
|
|
32
|
+
@event_stream.emit(Insika::Event.new(
|
|
33
|
+
type: :agent_updated, data: { agent_id: id },
|
|
34
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
35
|
+
))
|
|
36
|
+
@profile_source.fetch(id)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def validate_subagent_graph!(profile)
|
|
42
|
+
return if Array(profile.subagents).empty?
|
|
43
|
+
|
|
44
|
+
others = @profile_source.all.reject { |p| p.id.to_s == profile.id.to_s }
|
|
45
|
+
Insika::SubagentGraph.validate!(others + [profile])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: merges a patch into the
|
|
8
|
+
# general settings (timeouts/streaming/compaction) and persists it in the SettingsStore.
|
|
9
|
+
# Only what came in the patch changes; the rest (and the defaults) is preserved. Takes effect on the
|
|
10
|
+
# next turn that reads settings. -> Hash (resulting settings).
|
|
11
|
+
class UpdateSettings
|
|
12
|
+
def initialize(settings_store:, event_stream:)
|
|
13
|
+
@settings_store = settings_store
|
|
14
|
+
@event_stream = event_stream
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(command)
|
|
18
|
+
p = AgentPayload.symbolize(command.payload)
|
|
19
|
+
patch = p[:patch]
|
|
20
|
+
raise Insika::ValidationError, "patch must be an object" unless patch.nil? || patch.is_a?(Hash)
|
|
21
|
+
raise Insika::ValidationError, "empty patch" if patch.nil? || patch.empty?
|
|
22
|
+
|
|
23
|
+
settings = @settings_store.update(patch)
|
|
24
|
+
@event_stream.emit(Insika::Event.new(
|
|
25
|
+
type: :settings_updated,
|
|
26
|
+
data: { keys: patch.keys.map(&:to_s) },
|
|
27
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
28
|
+
))
|
|
29
|
+
settings
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: creates/edits an LLM provider
|
|
8
|
+
# (api/base_url/auth_header/api_key/models) in the LLMProviderStore and RECONFIGURES
|
|
9
|
+
# RubyLLM at runtime (LLMConfigurator) — swaps key/base without a restart.
|
|
10
|
+
# The `api_key` is sentinel-aware (__OCULTO__ preserves; "" clears; a new string
|
|
11
|
+
# replaces). Returns the MASKED record (the key never comes back in plaintext).
|
|
12
|
+
class UpsertLLMProvider
|
|
13
|
+
def initialize(provider_store:, configurator:, event_stream:)
|
|
14
|
+
@provider_store = provider_store
|
|
15
|
+
@configurator = configurator
|
|
16
|
+
@event_stream = event_stream
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(command)
|
|
20
|
+
p = AgentPayload.symbolize(command.payload)
|
|
21
|
+
masked = @provider_store.upsert(p) # validates `api`; returns masked
|
|
22
|
+
|
|
23
|
+
# Reconfigures only THIS provider (with the real key from the store, already reconciled).
|
|
24
|
+
result = @configurator.apply([@provider_store.get_raw(masked["api"])])
|
|
25
|
+
@event_stream.emit(Insika::Event.new(
|
|
26
|
+
type: :llm_provider_upserted,
|
|
27
|
+
data: { api: masked["api"], applied: result[:applied], skipped: result[:skipped] },
|
|
28
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
29
|
+
))
|
|
30
|
+
masked
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: creates/edits an MCP instance
|
|
8
|
+
# (transport/command/url/enabled + `env` credentials) in the McpStore. The
|
|
9
|
+
# credentials are sentinel-aware per key (__OCULTO__ preserves; "" clears;
|
|
10
|
+
# a new string replaces). Returns the MASKED record (env never comes back in
|
|
11
|
+
# plaintext). Durable config CRUD — running an MCP client against the
|
|
12
|
+
# instance is later runtime.
|
|
13
|
+
class UpsertMcp
|
|
14
|
+
def initialize(mcp_store:, event_stream:)
|
|
15
|
+
@mcp_store = mcp_store
|
|
16
|
+
@event_stream = event_stream
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(command)
|
|
20
|
+
p = AgentPayload.symbolize(command.payload)
|
|
21
|
+
masked = @mcp_store.upsert(p) # validates `name`; returns masked
|
|
22
|
+
|
|
23
|
+
@event_stream.emit(Insika::Event.new(
|
|
24
|
+
type: :mcp_upserted,
|
|
25
|
+
data: { name: masked["name"], enabled: masked["enabled"] },
|
|
26
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
27
|
+
))
|
|
28
|
+
masked
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: writes an agent's prompt file to the
|
|
8
|
+
# workspace (AgentFileStore). This is what gives each BIA its own identity — the
|
|
9
|
+
# Prompt provider reads these files via `profile.prompt_files`. Takes effect
|
|
10
|
+
# on the next dispatch (hot). -> { agent_id, file, updated_at }.
|
|
11
|
+
class WriteAgentFile
|
|
12
|
+
def initialize(profile_source:, agent_file_store:, event_stream:)
|
|
13
|
+
@profile_source = profile_source
|
|
14
|
+
@agent_files = agent_file_store
|
|
15
|
+
@event_stream = event_stream
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(command)
|
|
19
|
+
p = AgentPayload.symbolize(command.payload)
|
|
20
|
+
agent_id = AgentPayload.presence(p[:agent_id])
|
|
21
|
+
file = AgentPayload.presence(p[:file])
|
|
22
|
+
raise Insika::ValidationError, "agent_id is required" if agent_id.nil?
|
|
23
|
+
raise Insika::ValidationError, "file is required" if file.nil?
|
|
24
|
+
profile = @profile_source.fetch(agent_id) ||
|
|
25
|
+
(raise Insika::NotFoundError, "agent '#{agent_id}' not found")
|
|
26
|
+
|
|
27
|
+
# `.to_s` used to be unconditional here, which is how a JSON body carrying an
|
|
28
|
+
# OBJECT for `content` became a prompt file holding Ruby's #inspect of it. The
|
|
29
|
+
# store refuses that now; keep the coercion for scalars (a form always sends a
|
|
30
|
+
# String, and nil means "clear the file").
|
|
31
|
+
content = Insika::AgentFileStore.text!(p[:content], agent_id, file).to_s
|
|
32
|
+
entry = @agent_files.write(agent_id, file, content, create_only: !!p[:create_only])
|
|
33
|
+
# Writing a prompt registers it in prompt_files — otherwise the Prompt provider
|
|
34
|
+
# would never load it. It's the Command's job, not the dispatcher's.
|
|
35
|
+
register_prompt_file(profile, file)
|
|
36
|
+
emit(:agent_file_written, agent_id, file)
|
|
37
|
+
{ agent_id: agent_id, file: file, updated_at: entry["updated_at"] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def register_prompt_file(profile, file)
|
|
43
|
+
files = Array(profile.prompt_files).map(&:to_s)
|
|
44
|
+
return if files.include?(file)
|
|
45
|
+
|
|
46
|
+
@profile_source.put(Insika::AgentProfile.build(**profile.to_h.merge(prompt_files: files + [file])))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def emit(type, agent_id, file)
|
|
50
|
+
@event_stream.emit(Insika::Event.new(
|
|
51
|
+
type: type, data: { agent_id: agent_id, file: file },
|
|
52
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
53
|
+
))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Writes (upserts) a DATA-defined tool into the ToolStore and RELOADS the overlay +
|
|
8
|
+
# catalog — takes effect without a restart. Rejects a name that collides with a
|
|
9
|
+
# code tool (R3). Validating the definition (format, method/url, types,
|
|
10
|
+
# placeholders) is the ToolStore/ToolDefinition's job. Secrets (headers) reconcile
|
|
11
|
+
# in the store; the event carries only the name (0 leakage). -> { name, definition }.
|
|
12
|
+
class WriteDataTool
|
|
13
|
+
def initialize(tool_store:, registry:, tool_catalog:, event_stream:)
|
|
14
|
+
@tool_store = tool_store
|
|
15
|
+
@registry = registry
|
|
16
|
+
@tool_catalog = tool_catalog
|
|
17
|
+
@event_stream = event_stream
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(command)
|
|
21
|
+
p = AgentPayload.symbolize(command.payload)
|
|
22
|
+
name = AgentPayload.presence(p[:name])
|
|
23
|
+
raise Insika::ValidationError, "name is required" if name.nil?
|
|
24
|
+
raise Insika::ValidationError, "'#{name}' is already a code tool" if @registry.code_tool?(name)
|
|
25
|
+
|
|
26
|
+
masked = @tool_store.write(p, create_only: !!p[:create_only])
|
|
27
|
+
@registry.reload
|
|
28
|
+
@tool_catalog.reload
|
|
29
|
+
emit(name)
|
|
30
|
+
{ name: name, definition: masked }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def emit(name)
|
|
36
|
+
@event_stream.emit(Insika::Event.new(
|
|
37
|
+
type: :data_tool_written, data: { name: name },
|
|
38
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
39
|
+
))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: writes an eval case into the GoldenStore. This
|
|
8
|
+
# is what lets a domain owner add a case — above all its RUBRIC, the part of an eval
|
|
9
|
+
# that is plain language — without a checkout and a text editor.
|
|
10
|
+
#
|
|
11
|
+
# The payload carries the case as DATA (`case:`), the same mapping the YAML file
|
|
12
|
+
# holds; the store validates it with the one loader, so a case authored here and a
|
|
13
|
+
# case read from `evals/golden/` cannot diverge. -> { id, agent }.
|
|
14
|
+
class WriteGolden
|
|
15
|
+
def initialize(golden_store:, event_stream:)
|
|
16
|
+
@goldens = golden_store
|
|
17
|
+
@event_stream = event_stream
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(command)
|
|
21
|
+
p = AgentPayload.symbolize(command.payload)
|
|
22
|
+
raise Insika::ValidationError, "case is required" unless p[:case].is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
golden = @goldens.write(p[:case])
|
|
25
|
+
emit(:golden_written, id: golden.id, agent: golden.agent)
|
|
26
|
+
{ id: golden.id, agent: golden.agent }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def emit(type, **data)
|
|
32
|
+
@event_stream.emit(Insika::Event.new(type: type, data: data,
|
|
33
|
+
meta: { at: Time.now.utc.iso8601 }))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Control command: removes an eval case. The corpus on disk is the way back
|
|
38
|
+
# (`insika evals:import`), which is why this needs no version history.
|
|
39
|
+
# -> { id, existed }.
|
|
40
|
+
class DeleteGolden
|
|
41
|
+
def initialize(golden_store:, event_stream:)
|
|
42
|
+
@goldens = golden_store
|
|
43
|
+
@event_stream = event_stream
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def call(command)
|
|
47
|
+
p = AgentPayload.symbolize(command.payload)
|
|
48
|
+
id = AgentPayload.presence(p[:id])
|
|
49
|
+
raise Insika::ValidationError, "id is required" if id.nil?
|
|
50
|
+
|
|
51
|
+
existed = @goldens.delete(id)
|
|
52
|
+
@event_stream.emit(Insika::Event.new(type: :golden_deleted, data: { id: id, existed: existed },
|
|
53
|
+
meta: { at: Time.now.utc.iso8601 }))
|
|
54
|
+
{ id: id, existed: existed }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Commands
|
|
8
|
+
# Control command: writes a skill (complete SKILL.md) into
|
|
9
|
+
# the SkillStore and RELOADS the catalog — takes effect without a restart (hot).
|
|
10
|
+
# Validates the frontmatter (name required) before writing. -> { name, agent, updated_at }.
|
|
11
|
+
#
|
|
12
|
+
# `agent` (optional) writes into that agent's scope instead of the shared one: the
|
|
13
|
+
# SPECIALIZATION of a shared skill, or a skill private to one agent. Same `name`
|
|
14
|
+
# either way — the store position is the identity, so the frontmatter inside an
|
|
15
|
+
# override keeps saying the bare shared name and the level-1 catalog, load_skill,
|
|
16
|
+
# the labels and the events all keep displaying it.
|
|
17
|
+
class WriteSkill
|
|
18
|
+
def initialize(skill_store:, skill_catalog:, event_stream:)
|
|
19
|
+
@skill_store = skill_store
|
|
20
|
+
@skill_catalog = skill_catalog
|
|
21
|
+
@event_stream = event_stream
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(command)
|
|
25
|
+
p = AgentPayload.symbolize(command.payload)
|
|
26
|
+
name = AgentPayload.presence(p[:name])
|
|
27
|
+
agent = AgentPayload.presence(p[:agent])
|
|
28
|
+
content = p[:content].to_s
|
|
29
|
+
raise Insika::ValidationError, "name is required" if name.nil?
|
|
30
|
+
validate_frontmatter!(content)
|
|
31
|
+
|
|
32
|
+
rec = @skill_store.write(name, content, agent: agent, create_only: !!p[:create_only])
|
|
33
|
+
@skill_catalog.reload
|
|
34
|
+
emit(:skill_written, name, agent)
|
|
35
|
+
{ name: name, agent: agent, updated_at: rec["updated_at"] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def emit(type, name, agent)
|
|
41
|
+
@event_stream.emit(Insika::Event.new(
|
|
42
|
+
type: type, data: { name: name, agent: agent }.compact,
|
|
43
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
44
|
+
))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Mirrors the SkillCatalog parse: without YAML frontmatter with `name`, the skill
|
|
48
|
+
# would be silently ignored on reload — fail early, in the Command.
|
|
49
|
+
def validate_frontmatter!(content)
|
|
50
|
+
match = content.match(/\A---\s*\n(.*?)\n---\s*\n/m)
|
|
51
|
+
raise Insika::ValidationError, "SKILL.md missing YAML frontmatter (--- ... ---)" unless match
|
|
52
|
+
|
|
53
|
+
# Tolerant parser (Frontmatter): prose with `: ` in the description is valid —
|
|
54
|
+
# it must not become a Psych::SyntaxError (500). Only fails if `name` is missing.
|
|
55
|
+
meta = Insika::Frontmatter.parse(match[1])
|
|
56
|
+
raise Insika::ValidationError, "frontmatter missing `name`" if AgentPayload.presence(meta["name"]).nil?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command: writes a GLOBAL system file into the
|
|
8
|
+
# SystemFileStore. Unlike write_agent_file (per agent), these files
|
|
9
|
+
# apply to ALL agents — the Prompt provider injects them before the
|
|
10
|
+
# individual identity. Takes effect on the next turn (hot). -> { file, updated_at }.
|
|
11
|
+
class WriteSystemFile
|
|
12
|
+
def initialize(system_file_store:, event_stream:)
|
|
13
|
+
@system_files = system_file_store
|
|
14
|
+
@event_stream = event_stream
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(command)
|
|
18
|
+
p = AgentPayload.symbolize(command.payload)
|
|
19
|
+
file = AgentPayload.presence(p[:file])
|
|
20
|
+
raise Insika::ValidationError, "file is required" if file.nil?
|
|
21
|
+
|
|
22
|
+
entry = @system_files.write(file, p[:content].to_s, create_only: !!p[:create_only])
|
|
23
|
+
@event_stream.emit(Insika::Event.new(
|
|
24
|
+
type: :system_file_written, data: { file: file },
|
|
25
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
26
|
+
))
|
|
27
|
+
{ file: file, updated_at: entry["updated_at"] }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|