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,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# LIVE MCP tools — kills the McpToolIngestor snapshot.
|
|
5
|
+
#
|
|
6
|
+
# `entries` is CHEAP: it reads only McpStore#tools_cache (no I/O), the same
|
|
7
|
+
# contract the ToolStore-backed dynamic entries already give
|
|
8
|
+
# OverlayToolRegistry — Policy recomputes `candidate_tools` every turn, so
|
|
9
|
+
# an unreachable MCP server must never add real latency there.
|
|
10
|
+
#
|
|
11
|
+
# `refresh` is the ONLY thing that talks to a server ahead of time: it
|
|
12
|
+
# connects, lists live, and writes the result back to tools_cache (display/
|
|
13
|
+
# doctor). Calling a tool never depends on that cache — Insika::McpLiveTool
|
|
14
|
+
# always goes through the live, memoized client (and RubyLLM::MCP::Client
|
|
15
|
+
# does its own real `tools/list` on ITS first use per instance, regardless
|
|
16
|
+
# of whether `refresh` ever ran).
|
|
17
|
+
class McpToolRegistry
|
|
18
|
+
def initialize(mcp_store:, client_factory: Insika::McpClient.method(:for))
|
|
19
|
+
@mcp_store = mcp_store
|
|
20
|
+
@client_factory = client_factory
|
|
21
|
+
@clients = {}
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# -> [Registry::Entry] one per cached tool of every ENABLED instance.
|
|
26
|
+
def entries
|
|
27
|
+
@mcp_store.all_raw.select { |r| r["enabled"] }.flat_map { |r| entries_for(r) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Connects to `name` LIVE, lists its tools, and writes McpStore#tools_cache.
|
|
31
|
+
# ALWAYS drops any memoized client first and rebuilds from the current
|
|
32
|
+
# record: a stdio process can be `alive?` (still running) yet permanently
|
|
33
|
+
# broken (e.g. wrong transport args — see grafana-stg gotcha), and an
|
|
34
|
+
# edited command/url/env must take effect without a process restart —
|
|
35
|
+
# there's no SSH into a Railway dyno to do that by hand.
|
|
36
|
+
# -> the discovered [{"name","description","inputSchema"}]. Raises on a
|
|
37
|
+
# missing/disabled instance or a transport failure — the caller (a CLI
|
|
38
|
+
# verb/API route/UI button, PR3/PR4) decides how to surface it.
|
|
39
|
+
def refresh(name)
|
|
40
|
+
record = @mcp_store.get_raw(name.to_s)
|
|
41
|
+
raise Insika::NotFoundError, "MCP instance '#{name}' not found" if record.nil?
|
|
42
|
+
raise Insika::ValidationError, "MCP instance '#{name}' is disabled" unless record["enabled"]
|
|
43
|
+
|
|
44
|
+
evict(record["name"])
|
|
45
|
+
client = client_for(record)
|
|
46
|
+
discovered = client.tools.map { |t| { "name" => t.name, "description" => t.description, "inputSchema" => t.params_schema } }
|
|
47
|
+
@mcp_store.set_tools_cache(name, discovered)
|
|
48
|
+
discovered
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Stops and drops any memoized client for `name` (no-op if none), so the
|
|
52
|
+
# next `client_for` builds a fresh one off the current record. Public:
|
|
53
|
+
# called by `refresh` above AND by UpsertMcp/DeleteMcp right after they
|
|
54
|
+
# write the store — a Studio/CLI edit takes effect on that instance's
|
|
55
|
+
# NEXT tool call or refresh, no process restart required (there's no way
|
|
56
|
+
# to restart a process by hand on a Railway dyno).
|
|
57
|
+
def evict(name)
|
|
58
|
+
client = @mutex.synchronize { @clients.delete(name.to_s) }
|
|
59
|
+
client&.stop
|
|
60
|
+
rescue StandardError
|
|
61
|
+
nil # best-effort teardown of a possibly already-dead process
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def entries_for(record)
|
|
67
|
+
Array(record["tools_cache"]).map { |tool| entry_for(record, tool) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def entry_for(record, tool)
|
|
71
|
+
instance = record["name"]
|
|
72
|
+
Insika::Registry::Entry.new(
|
|
73
|
+
name: tool["name"], plugin: "mcp:#{instance}",
|
|
74
|
+
metadata: { optional: false, side_effect: true, group: "mcp:#{instance}", tags: [] },
|
|
75
|
+
factory: -> { build_tool(record, tool) }
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Lazy require (McpLiveTool < RubyLLM::Tool pulls in ruby_llm) — kept out
|
|
80
|
+
# of insika.rb load-time, loaded on the 1st instance (turn time), same
|
|
81
|
+
# discipline as OverlayToolRegistry#build_tool for data-tools.
|
|
82
|
+
def build_tool(record, tool)
|
|
83
|
+
require_relative "mcp_live_tool"
|
|
84
|
+
Insika::McpLiveTool.new(instance_name: record["name"], tool: tool, client_for: -> { client_for(record) })
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A started, MEMOIZED client for `record` — one real connection per
|
|
88
|
+
# instance name, reused across calls/turns. Raises on a gated/unreachable
|
|
89
|
+
# instance; only called from `refresh` and from a running McpLiveTool's
|
|
90
|
+
# `#execute` (which rescues) — never from `entries`/`build_tool`, so a
|
|
91
|
+
# downed server never breaks turn ASSEMBLY, only that tool's own call.
|
|
92
|
+
def client_for(record)
|
|
93
|
+
@mutex.synchronize do
|
|
94
|
+
client = (@clients[record["name"]] ||= @client_factory.call(record))
|
|
95
|
+
client.start unless client.alive?
|
|
96
|
+
client
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/insika/media.rb
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# WS9: the engine transports MEDIA, never meaning. Content parts ride the
|
|
5
|
+
# message contract — `{ "type": "text", "text": … }`, `{ "type": "image",
|
|
6
|
+
# "url": … }`, `{ "type": "audio", "url": … }`, `{ "type": "document",
|
|
7
|
+
# "url": … }` — and the Executor turns them into a turn: audio is
|
|
8
|
+
# transcribed (text marked `source: :voice`), images and documents attach
|
|
9
|
+
# to the model ask and the first URL of each kind is `{{ctx.image_url}}` /
|
|
10
|
+
# `{{ctx.document_url}}` for data tools. This class owns the PURE parts
|
|
11
|
+
# (normalization) and the STT SEAM (injectable — specs stub it; the default
|
|
12
|
+
# fetches the audio and transcribes via RubyLLM behind a lazy require, so
|
|
13
|
+
# the core stays gem-free at load). `Media::Output` is the generated-media
|
|
14
|
+
# half (WS9, saída): the turn can PRODUCE an image or an audio clip when the
|
|
15
|
+
# agent opted in (`AgentProfile#outputs`) AND the channel declared it can
|
|
16
|
+
# receive it (`channel.capabilities`) — nothing leaks by default.
|
|
17
|
+
module Media
|
|
18
|
+
# A single content part, normalized.
|
|
19
|
+
Part = Data.define(:type, :text, :url) do
|
|
20
|
+
def audio? = type == "audio"
|
|
21
|
+
def image? = type == "image"
|
|
22
|
+
def document? = type == "document"
|
|
23
|
+
def text? = type == "text"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# -> [Part]: normalize the raw parts (string|symbol keys), skipping anything
|
|
27
|
+
# that is not a well-formed text/image/audio/document part. Lenient on
|
|
28
|
+
# purpose — the SURFACE validates the contract with `well_formed?` (a
|
|
29
|
+
# malformed part is a 422 before dispatch); here a stray entry must not
|
|
30
|
+
# break the turn.
|
|
31
|
+
def self.parts(raw)
|
|
32
|
+
Array(raw).filter_map do |p|
|
|
33
|
+
next unless p.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
type = (p[:type] || p["type"]).to_s
|
|
36
|
+
url = (p[:url] || p["url"]).to_s
|
|
37
|
+
text = (p[:text] || p["text"]).to_s
|
|
38
|
+
case type
|
|
39
|
+
when "text" then text.empty? ? nil : Part.new("text", text, nil)
|
|
40
|
+
when "image", "audio", "document" then url.empty? ? nil : Part.new(type, nil, url)
|
|
41
|
+
else nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The SURFACE's contract check (server edge): true when EVERY entry is a
|
|
47
|
+
# well-formed content part — a Hash whose type is text (with text), image,
|
|
48
|
+
# audio or document (with url). The edge raises a 422 on the first
|
|
49
|
+
# offender; the engine itself stays lenient (`parts` skips strays so a
|
|
50
|
+
# non-HTTP transport that bypassed the edge cannot break a turn).
|
|
51
|
+
def self.well_formed?(raw)
|
|
52
|
+
Array(raw).all? do |p|
|
|
53
|
+
next false unless p.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
case (p[:type] || p["type"]).to_s
|
|
56
|
+
when "text" then !(p[:text] || p["text"]).to_s.empty?
|
|
57
|
+
when "image", "audio", "document" then !(p[:url] || p["url"]).to_s.empty?
|
|
58
|
+
# a part WITHOUT a type is admitted only as a bare text part (the
|
|
59
|
+
# shape the input joiner already tolerates) — anything else is refused.
|
|
60
|
+
when "" then !(p[:text] || p["text"]).to_s.empty?
|
|
61
|
+
else false
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The OUTPUT media kinds a channel may declare it can receive
|
|
67
|
+
# (`channel.capabilities`). The closed list is the "abstraction admits
|
|
68
|
+
# only what leaks" rule: an unknown value is refused at the edge, never
|
|
69
|
+
# silently ignored.
|
|
70
|
+
OUTPUT_CAPABILITIES = %w[image_output audio_output].freeze
|
|
71
|
+
|
|
72
|
+
# -> [String]: the capabilities a raw `channel` hash declares. Lenient on
|
|
73
|
+
# the key spelling (symbol|string) at both boundaries (request parse vs
|
|
74
|
+
# persisted command payload); [] = the channel declared nothing.
|
|
75
|
+
def self.channel_capabilities(raw)
|
|
76
|
+
channel = raw.is_a?(Hash) ? raw : {}
|
|
77
|
+
Array(channel[:capabilities] || channel["capabilities"]).map(&:to_s)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The ceilings on INBOUND media (a URL a consumer sent us). Both fetches
|
|
81
|
+
# stream into the cap and refuse past it: the bytes land in THIS process,
|
|
82
|
+
# so an uncapped one is a hostile URL away from growing it until it dies.
|
|
83
|
+
MAX_AUDIO_BYTES = 1_000_000 # a voice note, not a warehouse
|
|
84
|
+
MAX_IMAGE_BYTES = 5_000_000 # a photo, not a poster
|
|
85
|
+
MAX_DOCUMENT_BYTES = 10_000_000 # a prescription, not an archive
|
|
86
|
+
|
|
87
|
+
def self.audio_parts(parts) = parts.select(&:audio?)
|
|
88
|
+
def self.image_parts(parts) = parts.select(&:image?)
|
|
89
|
+
def self.document_parts(parts) = parts.select(&:document?)
|
|
90
|
+
|
|
91
|
+
# The STT seam: ->(url) { text } (default: fetch + RubyLLM transcription).
|
|
92
|
+
# Injected so a spec never touches the network; the default is built lazily
|
|
93
|
+
# when the turn first carries audio. `stt_prompt` is the Whisper-family
|
|
94
|
+
# vocabulary hint (product names, brand terms) — OPERATOR config
|
|
95
|
+
# (agent profile / deployment env), never customer input.
|
|
96
|
+
def self.default_transcriber(stt_model:, stt_language: nil, stt_prompt: nil)
|
|
97
|
+
lambda do |url|
|
|
98
|
+
fetch_and_transcribe(url, model: stt_model, language: stt_language, prompt: stt_prompt)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A file PATH, not bytes and not an Attachment: `RubyLLM::Transcription.
|
|
103
|
+
# transcribe` hands its argument to the PROVIDER's own `transcribe`, and the
|
|
104
|
+
# two shapes in this gem disagree — Gemini wraps it in `Attachment.new`
|
|
105
|
+
# itself (a raw byte String there is misread as a Pathname and blows up on
|
|
106
|
+
# any embedded null byte, which real audio has), while the DEFAULT
|
|
107
|
+
# `Provider#transcribe` (OpenAI, Mistral) calls `File.expand_path` on it
|
|
108
|
+
# directly and cannot take bytes/IO/Attachment at all. A tempfile is the
|
|
109
|
+
# one shape both accept. `assume_model_exists` is deliberately NOT passed:
|
|
110
|
+
# RubyLLM raises ArgumentError when it's true without an explicit
|
|
111
|
+
# `provider` (see ModelSelection#assume_model_exists?), and `stt_model` here
|
|
112
|
+
# is a bare ref like `utility_model` elsewhere — the registry resolves it.
|
|
113
|
+
def self.fetch_and_transcribe(url, model:, language:, prompt: nil)
|
|
114
|
+
require "net/http"
|
|
115
|
+
require "uri"
|
|
116
|
+
require "ruby_llm" # lazy — the core loads without it (load-guard)
|
|
117
|
+
require "tempfile"
|
|
118
|
+
|
|
119
|
+
bytes = fetch_binary(url)
|
|
120
|
+
options = { model: model }
|
|
121
|
+
options[:language] = language if language
|
|
122
|
+
options[:prompt] = prompt if prompt
|
|
123
|
+
Tempfile.create(["insika-media-", File.extname(filename_for(url).to_s)]) do |file|
|
|
124
|
+
file.binmode
|
|
125
|
+
file.write(bytes)
|
|
126
|
+
file.flush
|
|
127
|
+
RubyLLM::Transcription.transcribe(file.path, **options).text
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# An inbound URL -> a RubyLLM::Attachment over bytes WE fetched (egress-
|
|
132
|
+
# guarded, size-capped — the `media_attachment` recipe). Shared by the
|
|
133
|
+
# Executor (inbound image/document parts) and `Output.generate_image`
|
|
134
|
+
# (edit sources / mask): an io-like source (StringIO) is the branch of
|
|
135
|
+
# Attachment that takes bytes already held, so the provider gets base64
|
|
136
|
+
# rather than the URL — handing the raw URL to RubyLLM instead would leave
|
|
137
|
+
# the (uncapped) fetch to the gem.
|
|
138
|
+
def self.url_attachment(url, max_bytes: MAX_IMAGE_BYTES)
|
|
139
|
+
require "ruby_llm"
|
|
140
|
+
require "stringio"
|
|
141
|
+
|
|
142
|
+
bytes = fetch_binary(url, max_bytes: max_bytes)
|
|
143
|
+
RubyLLM::Attachment.new(StringIO.new(bytes), filename: filename_for(url))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The URL's basename, for the attachment's mime sniff (".png" -> image/png;
|
|
147
|
+
# a URL with no filename falls back to the content sniff RubyLLM does).
|
|
148
|
+
def self.filename_for(url)
|
|
149
|
+
require "uri"
|
|
150
|
+
name = File.basename(URI.parse(url).path.to_s)
|
|
151
|
+
name.empty? ? nil : name
|
|
152
|
+
rescue URI::InvalidURIError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Egress-guarded binary fetch of a media URL. Blocked like the webhook: the
|
|
157
|
+
# url is consumer config/input, so a private/loopback/metadata target is
|
|
158
|
+
# refused (SSRF) unless the deployment opts out. Size-capped (the caller
|
|
159
|
+
# picks the ceiling; the default is the audio one).
|
|
160
|
+
def self.fetch_binary(url, max_bytes: MAX_AUDIO_BYTES)
|
|
161
|
+
violation = Insika::EgressGuard.violation(url, **egress_opt_out)
|
|
162
|
+
raise Insika::MediaError, "media egress blocked for #{url}: #{violation}" if violation
|
|
163
|
+
|
|
164
|
+
uri = URI.parse(url)
|
|
165
|
+
opts = { use_ssl: uri.scheme == "https", open_timeout: 30, read_timeout: 60 }
|
|
166
|
+
Net::HTTP.start(uri.host, uri.port, opts) do |http|
|
|
167
|
+
buf = +"".b
|
|
168
|
+
http.request(Net::HTTP::Get.new(uri)) do |resp|
|
|
169
|
+
raise Insika::MediaError, "media fetch HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
|
|
170
|
+
|
|
171
|
+
resp.read_body { |chunk| buf << chunk; break if buf.bytesize > max_bytes }
|
|
172
|
+
end
|
|
173
|
+
raise Insika::MediaError, "media exceeds #{max_bytes} bytes" if buf.bytesize > max_bytes
|
|
174
|
+
buf
|
|
175
|
+
end
|
|
176
|
+
rescue URI::InvalidURIError
|
|
177
|
+
raise Insika::MediaError, "invalid media URL"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The opt-out the comment above promises, read from the SAME env the
|
|
181
|
+
# data-tool guard reads (INSIKA_EGRESS_ALLOW_HTTP / _ALLOW_PRIVATE): without
|
|
182
|
+
# this, a local run serving media over http:// ALWAYS failed, however the
|
|
183
|
+
# deployment was configured. INSIKA_EGRESS_HOSTS is deliberately NOT applied:
|
|
184
|
+
# that allowlist pins the handful of hosts a tool may call, while media URLs
|
|
185
|
+
# come from the channel's CDN — honouring it here would break every real
|
|
186
|
+
# deployment that narrows its tools.
|
|
187
|
+
def self.egress_opt_out
|
|
188
|
+
{ allow_http: Insika::EnvSchema.truthy?(ENV["INSIKA_EGRESS_ALLOW_HTTP"]),
|
|
189
|
+
allow_private: Insika::EnvSchema.truthy?(ENV["INSIKA_EGRESS_ALLOW_PRIVATE"]) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# WS9 (saída): generated media. The OUTPUT shape is an additive part —
|
|
193
|
+
# `{ "type": "image"|"audio", "mime_type": …, "base64": …, "model": … }`
|
|
194
|
+
# — that rides the turn's `output_parts` (terminal event + /v1/responses
|
|
195
|
+
# envelope), NEVER the answer text: the customer's channel consumes the
|
|
196
|
+
# bytes, the model's prose stays the answer.
|
|
197
|
+
#
|
|
198
|
+
# The GENERATION SEAMS are injectable like the STT seam: each is a
|
|
199
|
+
# `->(content, config) { [ part_hash, usage_hash ] }` (part_hash already
|
|
200
|
+
# carries its "type"), specs stub them, and the defaults hit the provider
|
|
201
|
+
# behind lazy requires:
|
|
202
|
+
# · image — RubyLLM.paint (the gem has vision AND painting), billed
|
|
203
|
+
# tokens merged into the turn's usage like any ask;
|
|
204
|
+
# · tts — RubyLLM still has NO speech API (as of 1.16.0), so the default
|
|
205
|
+
# is a thin POST to the OpenAI-compatible `<base>/audio/speech`
|
|
206
|
+
# endpoint (base + key from the provider config the chat uses — a
|
|
207
|
+
# deployment pointing OpenAI at a gateway keeps TTS pointing there).
|
|
208
|
+
# OpenAI's speech API reports no token usage; the part carries the
|
|
209
|
+
# model so the consumer can price it, and the turn counts the call.
|
|
210
|
+
module Output
|
|
211
|
+
DEFAULT_IMAGE_SIZE = "1024x1024"
|
|
212
|
+
DEFAULT_TTS_MODEL = "tts-1"
|
|
213
|
+
DEFAULT_TTS_VOICE = "alloy"
|
|
214
|
+
DEFAULT_TTS_FORMAT = "mp3"
|
|
215
|
+
# Base64 inlines into the envelope — a cap so a pathological generation
|
|
216
|
+
# cannot blow up the SSE frame. A generated 1024x1024 PNG sits well under.
|
|
217
|
+
MAX_EMBEDDED_BYTES = 8 * 1024 * 1024
|
|
218
|
+
# Edit sources on one `paint(with:)` call — a fitting room needs a
|
|
219
|
+
# handful of angles, not a gallery.
|
|
220
|
+
MAX_SOURCE_IMAGES = 4
|
|
221
|
+
|
|
222
|
+
class << self
|
|
223
|
+
# -> { image: seam, tts: seam } with the DEFAULTS bound to a context
|
|
224
|
+
# (the graph's RubyLLM::Context when it owns credentials — nil = the
|
|
225
|
+
# process-wide RubyLLM constant). Built lazily on first generation so
|
|
226
|
+
# the core loads without ruby_llm (load-guard).
|
|
227
|
+
def defaults(context:)
|
|
228
|
+
{
|
|
229
|
+
image: ->(prompt, config) { generate_image(prompt, config: config, context: context) },
|
|
230
|
+
tts: ->(text, config) { synthesize_speech(text, config: config, context: context) }
|
|
231
|
+
}
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# -> [Part, usage]: paint via RubyLLM — text-to-image when the config
|
|
235
|
+
# carries no sources (byte-identical to before this call), image
|
|
236
|
+
# EDITING when it does: `source_urls`/`source_attachments` ride
|
|
237
|
+
# `paint(with:)`, `mask_url` rides `paint(mask:)`. usage is the
|
|
238
|
+
# provider's token counts ({ input_tokens:, output_tokens: } — merged
|
|
239
|
+
# into the turn's usage by the Executor); a provider without counts
|
|
240
|
+
# reports nothing.
|
|
241
|
+
def generate_image(prompt, config:, context:)
|
|
242
|
+
require "ruby_llm" # lazy — the core loads without it (load-guard)
|
|
243
|
+
|
|
244
|
+
cfg = Insika::Coercion.deep_stringify(config || {})
|
|
245
|
+
model = Insika::Coercion.presence(cfg["model"]) || image_model(context)
|
|
246
|
+
# `assume_model_exists` is deliberately NOT passed: RubyLLM raises
|
|
247
|
+
# ArgumentError when it's true without an explicit `provider` (see
|
|
248
|
+
# ModelSelection#assume_model_exists?), and `model` here is a bare
|
|
249
|
+
# ref like `utility_model` elsewhere — the registry resolves it.
|
|
250
|
+
api = context || RubyLLM
|
|
251
|
+
image = api.paint(prompt.to_s, model: model,
|
|
252
|
+
size: presence(cfg["size"]) || DEFAULT_IMAGE_SIZE,
|
|
253
|
+
with: source_attachments(cfg), mask: mask_attachment(cfg))
|
|
254
|
+
data = image.respond_to?(:data) ? image.data : nil
|
|
255
|
+
raise Insika::MediaError, "image generation returned no embeddable data" if data.to_s.empty?
|
|
256
|
+
|
|
257
|
+
enforce_embedded_size!(data, "generated image")
|
|
258
|
+
mime = image.respond_to?(:mime_type) ? image.mime_type : nil
|
|
259
|
+
model_id = image.respond_to?(:model_id) ? image.model_id : nil
|
|
260
|
+
usage = image.respond_to?(:usage) ? token_usage(image.usage) : {}
|
|
261
|
+
part = { "type" => "image", "mime_type" => presence(mime) || "image/png",
|
|
262
|
+
"base64" => data, "model" => presence(model_id) }
|
|
263
|
+
[part.compact, usage]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# -> [Part, {}]: synthesize speech via the OpenAI-compatible
|
|
267
|
+
# `<base>/audio/speech` endpoint. `context` supplies the base URL + key
|
|
268
|
+
# (the same config the chat uses — see `speech_endpoint`). The bytes
|
|
269
|
+
# embed base64 in the part; the usage is empty (no token counts on the
|
|
270
|
+
# speech API) and the part carries the model for consumer-side pricing.
|
|
271
|
+
def synthesize_speech(text, config:, context:)
|
|
272
|
+
require "net/http"
|
|
273
|
+
require "uri"
|
|
274
|
+
require "json"
|
|
275
|
+
require "base64"
|
|
276
|
+
|
|
277
|
+
cfg = Insika::Coercion.deep_stringify(config || {})
|
|
278
|
+
model = presence(cfg["model"]) || DEFAULT_TTS_MODEL
|
|
279
|
+
voice = presence(cfg["voice"]) || DEFAULT_TTS_VOICE
|
|
280
|
+
format = presence(cfg["format"]) || DEFAULT_TTS_FORMAT
|
|
281
|
+
base, key = speech_endpoint(context)
|
|
282
|
+
if key.to_s.empty?
|
|
283
|
+
raise Insika::MediaError,
|
|
284
|
+
"TTS needs an OpenAI API key (provider config) — set it on the " \
|
|
285
|
+
"provider the agent uses, or inject a tts seam"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
uri = URI.parse("#{base}/audio/speech")
|
|
289
|
+
req = Net::HTTP::Post.new(uri)
|
|
290
|
+
req["Authorization"] = "Bearer #{key}"
|
|
291
|
+
req["Content-Type"] = "application/json"
|
|
292
|
+
req.body = JSON.generate(model: model, voice: voice, input: text.to_s,
|
|
293
|
+
response_format: format)
|
|
294
|
+
opts = { use_ssl: uri.scheme == "https", open_timeout: 30, read_timeout: 60 }
|
|
295
|
+
bytes = Net::HTTP.start(uri.host, uri.port, opts) do |http|
|
|
296
|
+
resp = http.request(req)
|
|
297
|
+
raise Insika::MediaError, "TTS HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
|
|
298
|
+
|
|
299
|
+
# stream into the cap — a rogue/broken endpoint must not grow the
|
|
300
|
+
# process past MAX_EMBEDDED_BYTES before the refusal.
|
|
301
|
+
buf = +"".b
|
|
302
|
+
resp.read_body do |chunk|
|
|
303
|
+
buf << chunk
|
|
304
|
+
break if buf.bytesize > MAX_EMBEDDED_BYTES
|
|
305
|
+
end
|
|
306
|
+
buf
|
|
307
|
+
end
|
|
308
|
+
enforce_embedded_size!(bytes, "synthesized speech")
|
|
309
|
+
part = { "type" => "audio", "mime_type" => mime_for(format), "base64" => Base64.strict_encode64(bytes), "model" => model }
|
|
310
|
+
[part.compact, {}]
|
|
311
|
+
rescue URI::InvalidURIError
|
|
312
|
+
raise Insika::MediaError, "invalid TTS endpoint"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
private
|
|
316
|
+
|
|
317
|
+
# The OpenAI-compatible base URL + key behind the chat's provider
|
|
318
|
+
# config. A RubyLLM::Context owns the deployment's credentials; the
|
|
319
|
+
# global config is the fallback (a graph without its own context).
|
|
320
|
+
def speech_endpoint(context)
|
|
321
|
+
config = context.respond_to?(:config) ? context.config : RubyLLM.config
|
|
322
|
+
base = config.respond_to?(:openai_api_base) ? config.openai_api_base : nil
|
|
323
|
+
key = config.respond_to?(:openai_api_key) ? config.openai_api_key : nil
|
|
324
|
+
[presence(base) || "https://api.openai.com/v1", key]
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def image_model(context)
|
|
328
|
+
config = context.respond_to?(:config) ? context.config : RubyLLM.config
|
|
329
|
+
config.respond_to?(:default_image_model) ? config.default_image_model : nil
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# -> [Attachment] | nil: the edit sources for `paint(with:)`. Pre-built
|
|
333
|
+
# attachments win (the tool's DEFAULT source — the turn's own inbound
|
|
334
|
+
# images, already fetched bytes, no URL round-trip); otherwise explicit
|
|
335
|
+
# `source_urls` are fetched through the SAME capped, egress-guarded
|
|
336
|
+
# path images always used. nil (never []) when there are none — a
|
|
337
|
+
# provider without OpenAI's `editing?` leniency raises on a non-nil
|
|
338
|
+
# `with:`, and a text-to-image turn must stay byte-identical.
|
|
339
|
+
def source_attachments(cfg)
|
|
340
|
+
built = cfg["source_attachments"]
|
|
341
|
+
return built if built.is_a?(Array) && built.any?
|
|
342
|
+
|
|
343
|
+
urls = Array(cfg["source_urls"]).map(&:to_s).reject(&:empty?).first(MAX_SOURCE_IMAGES)
|
|
344
|
+
urls.empty? ? nil : urls.map { |u| Insika::Media.url_attachment(u, max_bytes: Insika::Media::MAX_IMAGE_BYTES) }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# -> Attachment | nil: the optional edit mask for `paint(mask:)`.
|
|
348
|
+
def mask_attachment(cfg)
|
|
349
|
+
url = presence(cfg["mask_url"])
|
|
350
|
+
return nil unless url
|
|
351
|
+
|
|
352
|
+
Insika::Media.url_attachment(url, max_bytes: Insika::Media::MAX_IMAGE_BYTES)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def token_usage(raw)
|
|
356
|
+
usage = raw.is_a?(Hash) ? raw : {}
|
|
357
|
+
{
|
|
358
|
+
input_tokens: usage[:input_tokens] || usage["input_tokens"] || usage["prompt_tokens"],
|
|
359
|
+
output_tokens: usage[:output_tokens] || usage["output_tokens"] || usage["completion_tokens"]
|
|
360
|
+
}.compact
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def mime_for(format)
|
|
364
|
+
{ "mp3" => "audio/mpeg", "opus" => "audio/opus", "aac" => "audio/aac",
|
|
365
|
+
"wav" => "audio/wav", "flac" => "audio/flac" }[format.to_s] || "audio/mpeg"
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def enforce_embedded_size!(data, label)
|
|
369
|
+
size = data.respond_to?(:bytesize) ? data.bytesize : data.to_s.bytesize
|
|
370
|
+
return if size <= MAX_EMBEDDED_BYTES
|
|
371
|
+
|
|
372
|
+
raise Insika::MediaError,
|
|
373
|
+
"#{label} too large to embed (#{size} bytes > #{MAX_EMBEDDED_BYTES})"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def presence(value)
|
|
377
|
+
Insika::Coercion.presence(value)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha2"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Insika
|
|
8
|
+
# append-only, per-cell audit of MEMORY mutations. Every
|
|
9
|
+
# operator mutation of a cell appends a line (who, when, what, old->new
|
|
10
|
+
# digests). The entry holds DIGESTS of values, never the values — the forget
|
|
11
|
+
# line records that a deletion happened without the deleted content (the
|
|
12
|
+
# digest is not invertible, and keys are fact names — provenance, not
|
|
13
|
+
# payload). Capped per cell (no retention hook, no delete path: the audit
|
|
14
|
+
# outlives what it describes). `record` rescues EVERYTHING — a failed audit
|
|
15
|
+
# write never fails the mutation it describes.
|
|
16
|
+
#
|
|
17
|
+
# The capped-list RMW caveat (context_trace_store.rb's discipline): one cell
|
|
18
|
+
# key, written on the command's fiber; a cross-process race loses the loser's
|
|
19
|
+
# append — a trace-level loss, not a correctness one.
|
|
20
|
+
class MemoryAuditStore
|
|
21
|
+
SCOPE = "memory_audit" # store key = the memory cell scope
|
|
22
|
+
MAX_PER_CELL = 200 # oldest dropped; the cap bounds growth
|
|
23
|
+
|
|
24
|
+
Entry = Data.define(:at, :action, :actor, :key, :tenant, :customer,
|
|
25
|
+
:old_hash, :new_hash, :note)
|
|
26
|
+
|
|
27
|
+
def initialize(store:, clock: nil)
|
|
28
|
+
@store = store
|
|
29
|
+
@clock = clock # -> Time, injectable for specs
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# action: "put" | "forget" | "purge". key = the fact name (provenance,
|
|
33
|
+
# not payload). Appends + caps. Rescues EVERYTHING -> Entry | nil (the
|
|
34
|
+
# audit never breaks a command).
|
|
35
|
+
def record(cell:, action:, actor:, key: nil, tenant: nil, customer: nil,
|
|
36
|
+
old_hash: nil, new_hash: nil, note: nil)
|
|
37
|
+
entry = {
|
|
38
|
+
"at" => timestamp,
|
|
39
|
+
"action" => action.to_s,
|
|
40
|
+
"actor" => actor.to_s,
|
|
41
|
+
"key" => Coercion.presence(key),
|
|
42
|
+
"tenant" => Coercion.presence(tenant),
|
|
43
|
+
"customer" => Coercion.presence(customer),
|
|
44
|
+
"old_hash" => Coercion.presence(old_hash),
|
|
45
|
+
"new_hash" => Coercion.presence(new_hash),
|
|
46
|
+
"note" => Coercion.presence(note)
|
|
47
|
+
}
|
|
48
|
+
list = (@store.get(SCOPE, cell.to_s) || []) + [entry]
|
|
49
|
+
@store.set(SCOPE, cell.to_s, list.last(MAX_PER_CELL))
|
|
50
|
+
to_entry(entry)
|
|
51
|
+
rescue StandardError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# -> [Entry] most recent first. [] if none. A broken backend degrades to
|
|
56
|
+
# [] — the audit is read to RENDER, never to gate.
|
|
57
|
+
def for_cell(cell, limit: 100)
|
|
58
|
+
Array(@store.get(SCOPE, cell.to_s)).reverse.first(limit).map { |e| to_entry(e) }
|
|
59
|
+
rescue StandardError
|
|
60
|
+
[]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The digest the callers share: SHA-256 hexdigest of JSON.generate(value)
|
|
64
|
+
# (or value.to_s for non-JSON scalars). -> String
|
|
65
|
+
def self.digest(value)
|
|
66
|
+
payload = case value
|
|
67
|
+
when Hash, Array then JSON.generate(value)
|
|
68
|
+
else value.to_s
|
|
69
|
+
end
|
|
70
|
+
Digest::SHA256.hexdigest(payload)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def to_entry(record)
|
|
76
|
+
Entry.new(at: record["at"], action: record["action"], actor: record["actor"],
|
|
77
|
+
key: record["key"], tenant: record["tenant"], customer: record["customer"],
|
|
78
|
+
old_hash: record["old_hash"], new_hash: record["new_hash"], note: record["note"])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def timestamp = (clock ? clock.call : Time.now.utc).utc.iso8601(6)
|
|
82
|
+
|
|
83
|
+
def clock = @clock
|
|
84
|
+
end
|
|
85
|
+
end
|