insika 0.9.0 → 1.0.1
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 +237 -2
- data/README.md +5 -3
- data/bin/insika +3 -0
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +90 -7
- data/docs/ARCHITECTURE.md +12 -7
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CONTEXT.md +63 -0
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +57 -11
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +33 -4
- data/docs/FACTS.md +8 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +7 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +143 -22
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +22 -13
- data/docs/RELEASING.md +5 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +7 -0
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +111 -9
- data/docs/operate.md +3 -0
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +78 -8
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/chat_builder.rb +79 -25
- data/lib/insika/checkpoint.rb +6 -1
- data/lib/insika/checkpoint_store.rb +59 -7
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/delete_kit.rb +26 -0
- data/lib/insika/commands/gate_harvest.rb +3 -0
- data/lib/insika/commands/review_guardrail_flag.rb +38 -0
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +11 -1
- data/lib/insika/commands/send_message.rb +9 -3
- data/lib/insika/commands/update_settings.rb +23 -1
- data/lib/insika/commands/write_kit.rb +27 -0
- data/lib/insika/compaction.rb +33 -7
- data/lib/insika/config_store.rb +87 -7
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +2 -2
- data/lib/insika/context/providers/knowledge.rb +25 -2
- data/lib/insika/context/providers/memory.rb +53 -4
- data/lib/insika/context/providers/pending_confirmation.rb +52 -0
- data/lib/insika/context/providers/prompt.rb +17 -4
- data/lib/insika/context/providers/session.rb +39 -3
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +34 -4
- data/lib/insika/dsl/runtime.rb +36 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +13 -5
- data/lib/insika/edge_limiter.rb +23 -15
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/evals/assertions.rb +171 -16
- data/lib/insika/evals/golden.rb +141 -4
- data/lib/insika/evals/judge.rb +19 -10
- data/lib/insika/evals/moderator_compare.rb +47 -0
- data/lib/insika/evals/report.rb +61 -9
- data/lib/insika/evals/runner.rb +44 -4
- data/lib/insika/evals/simulator.rb +10 -1
- data/lib/insika/evals/transport.rb +200 -0
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +29 -8
- data/lib/insika/executor.rb +668 -141
- data/lib/insika/harvest.rb +26 -14
- data/lib/insika/harvest_engine.rb +1 -0
- data/lib/insika/harvest_store.rb +12 -0
- data/lib/insika/http_client.rb +87 -4
- data/lib/insika/kits.rb +54 -0
- data/lib/insika/knowledge.rb +34 -20
- data/lib/insika/llm_configurator.rb +46 -4
- data/lib/insika/llm_provider_store.rb +1 -3
- data/lib/insika/llm_trace_store.rb +54 -0
- data/lib/insika/mcp_client.rb +27 -63
- data/lib/insika/mcp_live_tool.rb +55 -9
- data/lib/insika/mcp_store.rb +32 -1
- data/lib/insika/mcp_tool_registry.rb +69 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +4 -3
- data/lib/insika/memory_trim.rb +60 -0
- data/lib/insika/middleware.rb +12 -3
- data/lib/insika/model_metrics_store.rb +285 -0
- data/lib/insika/model_registry.rb +110 -0
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +14 -21
- data/lib/insika/model_visible.rb +17 -6
- data/lib/insika/onboarding.rb +1 -0
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +25 -2
- data/lib/insika/pack_importer.rb +54 -9
- data/lib/insika/packaging.rb +14 -5
- data/lib/insika/pending_action_store.rb +45 -18
- data/lib/insika/plugin/loader.rb +4 -2
- data/lib/insika/profile_source.rb +4 -0
- data/lib/insika/provider_error_classifier.rb +18 -3
- data/lib/insika/queue_policy.rb +7 -2
- data/lib/insika/refinement/proposer.rb +42 -17
- data/lib/insika/reliability.rb +12 -3
- data/lib/insika/router/app.rb +14 -5
- data/lib/insika/router/session_key.rb +35 -5
- data/lib/insika/safety/config.rb +20 -3
- data/lib/insika/safety/corpus.rb +10 -3
- data/lib/insika/safety/factory.rb +39 -1
- data/lib/insika/safety/input_guardrail.rb +21 -1
- data/lib/insika/safety/judge_moderator.rb +70 -0
- data/lib/insika/safety/output_validator.rb +29 -5
- data/lib/insika/safety/prompt_echo.rb +89 -0
- data/lib/insika/schema_guard.rb +53 -76
- data/lib/insika/server/app.rb +17 -8
- data/lib/insika/server/responses.rb +19 -1
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +159 -10
- data/lib/insika/settings_store.rb +47 -1
- data/lib/insika/shared_conversations.rb +305 -0
- data/lib/insika/skill_catalog.rb +28 -2
- data/lib/insika/skill_store.rb +6 -0
- data/lib/insika/soak/runner.rb +1 -1
- data/lib/insika/steer_injector.rb +7 -48
- data/lib/insika/store.rb +29 -1
- data/lib/insika/stores/json_value.rb +37 -0
- data/lib/insika/stores/memory.rb +22 -35
- data/lib/insika/stores/postgres.rb +295 -0
- data/lib/insika/stores/sqlite.rb +257 -43
- data/lib/insika/stores/turn_counter.rb +25 -0
- data/lib/insika/studio/app.rb +1453 -161
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +25 -25
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
- data/lib/insika/studio/forms.rb +171 -9
- data/lib/insika/studio/nav_icons.rb +3 -0
- data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
- data/lib/insika/studio/views/_agent_picker.erb +15 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
- data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
- data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
- data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
- data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
- data/lib/insika/studio/views/_agents_master.erb +8 -12
- data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
- data/lib/insika/studio/views/_cfg_budget.erb +20 -0
- data/lib/insika/studio/views/_cfg_distill.erb +26 -0
- data/lib/insika/studio/views/_cfg_followup.erb +30 -0
- data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
- data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
- data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
- data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
- data/lib/insika/studio/views/_chats_master.erb +66 -0
- data/lib/insika/studio/views/_customers_master.erb +37 -0
- data/lib/insika/studio/views/_empty.erb +15 -0
- data/lib/insika/studio/views/_facts_master.erb +42 -0
- data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
- data/lib/insika/studio/views/_identity.erb +16 -0
- data/lib/insika/studio/views/_kpi.erb +17 -0
- data/lib/insika/studio/views/_message.erb +40 -2
- data/lib/insika/studio/views/_model_chart.erb +82 -0
- data/lib/insika/studio/views/_refinement_master.erb +38 -0
- data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
- data/lib/insika/studio/views/_tool_users.erb +17 -0
- data/lib/insika/studio/views/_why.erb +53 -0
- data/lib/insika/studio/views/agent_detail.erb +17 -10
- data/lib/insika/studio/views/agents.erb +28 -16
- data/lib/insika/studio/views/approvals.erb +16 -11
- data/lib/insika/studio/views/artifact.erb +20 -7
- data/lib/insika/studio/views/artifacts.erb +19 -14
- data/lib/insika/studio/views/chats.erb +22 -29
- data/lib/insika/studio/views/customer.erb +88 -43
- data/lib/insika/studio/views/customers.erb +16 -26
- data/lib/insika/studio/views/evals.erb +49 -31
- data/lib/insika/studio/views/fact_detail.erb +114 -0
- data/lib/insika/studio/views/facts.erb +15 -116
- data/lib/insika/studio/views/followups.erb +63 -52
- data/lib/insika/studio/views/funnel.erb +104 -43
- data/lib/insika/studio/views/harvest.erb +85 -59
- data/lib/insika/studio/views/home.erb +138 -23
- data/lib/insika/studio/views/inbox.erb +36 -0
- data/lib/insika/studio/views/kits.erb +176 -0
- data/lib/insika/studio/views/knowledge.erb +92 -80
- data/lib/insika/studio/views/layout.erb +42 -11
- data/lib/insika/studio/views/live.erb +45 -0
- data/lib/insika/studio/views/mcp.erb +43 -13
- data/lib/insika/studio/views/models.erb +187 -0
- data/lib/insika/studio/views/parity.erb +38 -32
- data/lib/insika/studio/views/playground.erb +13 -9
- data/lib/insika/studio/views/refinement.erb +15 -224
- data/lib/insika/studio/views/refinement_detail.erb +243 -0
- data/lib/insika/studio/views/session.erb +152 -117
- data/lib/insika/studio/views/settings.erb +86 -22
- data/lib/insika/studio/views/skills.erb +53 -19
- data/lib/insika/studio/views/system_files.erb +7 -7
- data/lib/insika/studio/views/task.erb +45 -15
- data/lib/insika/studio/views/tasks.erb +35 -18
- data/lib/insika/studio/views/tools.erb +62 -73
- data/lib/insika/task_actor.rb +4 -1
- data/lib/insika/task_store.rb +67 -1
- data/lib/insika/telemetry/pricing.rb +16 -14
- data/lib/insika/telemetry/recorder.rb +103 -9
- data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
- data/lib/insika/telemetry.rb +12 -2
- data/lib/insika/templates/daily-digest/README.md +13 -0
- data/lib/insika/templates/daily-digest/agent.rb +22 -11
- data/lib/insika/testing/store_contract.rb +65 -0
- data/lib/insika/text_tool_call.rb +66 -0
- data/lib/insika/tick.rb +7 -1
- data/lib/insika/token_store.rb +5 -0
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +10 -4
- data/lib/insika/tool_envelope.rb +165 -28
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +3 -0
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +2 -2
- data/lib/insika/tools/a2a_remote.rb +1 -1
- data/lib/insika/tools/agent_enum.rb +1 -1
- data/lib/insika/tools/confirm_pending.rb +112 -0
- data/lib/insika/tools/data_defined_tool.rb +23 -15
- data/lib/insika/tools/generate_image.rb +2 -2
- data/lib/insika/tools/load_knowledge.rb +7 -3
- data/lib/insika/tools/load_skill.rb +3 -3
- data/lib/insika/tools/present.rb +1 -8
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +3 -5
- data/lib/insika/tools/save_artifact.rb +16 -6
- data/lib/insika/tools/schedule_followup.rb +3 -3
- data/lib/insika/tools/stuck_signal.rb +3 -3
- data/lib/insika/tools/subagent.rb +5 -5
- data/lib/insika/tools/subagents.rb +3 -3
- data/lib/insika/tools/tool_search.rb +7 -5
- data/lib/insika/tools/tts.rb +2 -2
- data/lib/insika/tools/update_briefing.rb +4 -4
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +18 -0
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +81 -16
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +19 -3
- metadata +69 -13
- data/lib/insika/commands/import_mcp_tools.rb +0 -48
- data/lib/insika/mcp_http_client.rb +0 -67
- data/lib/insika/mcp_tool_ingestor.rb +0 -165
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'digest'
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Required HTTP persistence. The native backend owns the execution outbox;
|
|
8
|
+
# the central service alone owns settled conversation history.
|
|
9
|
+
class SharedConversations
|
|
10
|
+
class Recovered < StandardError
|
|
11
|
+
attr_reader :content, :output_parts, :attachments
|
|
12
|
+
def initialize(content, output_parts: [], attachments: [])
|
|
13
|
+
@content, @output_parts, @attachments = content, output_parts, attachments
|
|
14
|
+
super('recorded shared result recovered')
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
|
|
18
|
+
SCOPE = 'shared_conversations'
|
|
19
|
+
|
|
20
|
+
def initialize(url:, token:, store:)
|
|
21
|
+
require 'uri'
|
|
22
|
+
@url = URI(url)
|
|
23
|
+
unless %w[http https].include?(@url.scheme) && @url.host && !@url.userinfo && !@url.query && !@url.fragment
|
|
24
|
+
raise StoreError, 'invalid shared conversation URL'
|
|
25
|
+
end
|
|
26
|
+
raise StoreError, 'shared conversation token missing' if token.to_s.empty?
|
|
27
|
+
raise StoreError, 'shared conversations require a durable native backend' unless Stores.durable?(store)
|
|
28
|
+
@token, @store = token, store
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def begin_turn(task:, profile:)
|
|
32
|
+
data, payload = identity(task)
|
|
33
|
+
acquire = !payload.fetch('shared_conversation').key?('generation')
|
|
34
|
+
path = path_for(data)
|
|
35
|
+
conversation = request('GET', path)
|
|
36
|
+
key = key_for(data)
|
|
37
|
+
old = @store.get(SCOPE,key)
|
|
38
|
+
check_identity!(data, conversation, ownership: !acquire || !!old)
|
|
39
|
+
if old
|
|
40
|
+
raise StoreError, 'shared turn input differs' unless old['input_digest'] == input_digest(payload)
|
|
41
|
+
raise StoreError, 'shared turn requires native reconciliation' unless old['messages']
|
|
42
|
+
flush(data, old)
|
|
43
|
+
raise Recovered.new(old['messages'].last['content'].filter_map { _1['text'] }.join("\n"), output_parts: old.fetch('output_parts', []), attachments: old.fetch('delivery_attachments', []))
|
|
44
|
+
end
|
|
45
|
+
raise StoreError, 'shared turn requires native reconciliation' if conversation['active_turn']
|
|
46
|
+
data = data.merge('generation'=>conversation['generation'] + (conversation['assigned_harness'] == 'insika' ? 0 : 1)) if acquire
|
|
47
|
+
history = history_at(path, conversation.fetch('last_sequence'))
|
|
48
|
+
request('PUT', path+'/bindings/insika', {'generation'=>data['generation'], 'native_session_id'=>task.session_id || task.id,
|
|
49
|
+
'synchronized_sequence'=>conversation['last_sequence']}) unless acquire
|
|
50
|
+
input = canonical(task, {'role'=>'user','content'=>payload.fetch('user_text')}, id: data['message_id'])
|
|
51
|
+
input['origin'] = payload['origin'] || 'customer'
|
|
52
|
+
input['content'] = data['content'] if data['content']
|
|
53
|
+
record = {'native_run_id'=>task.id, 'input_digest'=>input_digest(payload), 'sequence'=>conversation['last_sequence'] + 1, 'generation'=>data['generation']}
|
|
54
|
+
# Write first: an ambiguous admission or registration must never authorize
|
|
55
|
+
# another execution. Only a recorded result can be flushed automatically.
|
|
56
|
+
@store.transaction do
|
|
57
|
+
raise StoreError, 'shared turn requires native reconciliation' if @store.get(SCOPE,key)
|
|
58
|
+
@store.set(SCOPE,key,record)
|
|
59
|
+
end
|
|
60
|
+
admission = {'id'=>data['turn_id'],'generation'=>data['generation'],
|
|
61
|
+
'expected_sequence'=>conversation['last_sequence'],'message'=>input}
|
|
62
|
+
admission.merge!('acquire'=>true, 'generation'=>conversation['generation'], 'native_session_id'=>task.session_id || task.id) if acquire
|
|
63
|
+
turn = request('POST', path+'/turns', admission)
|
|
64
|
+
raise StoreError, 'shared acquired generation differs' if acquire && turn['generation'] != data['generation']
|
|
65
|
+
raise StoreError, 'shared turn requires native reconciliation' if turn['native_run_id'] || turn['state'] != 'running'
|
|
66
|
+
request('PUT', path+"/turns/#{data['turn_id']}/native-run", {'generation'=>data['generation'],'native_run_id'=>task.id})
|
|
67
|
+
project(history, path)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def resume_turn(task:, profile:, checkpoint:)
|
|
71
|
+
data, = identity(task)
|
|
72
|
+
path = path_for(data)
|
|
73
|
+
conversation = request('GET',path)
|
|
74
|
+
check_identity!(data,conversation)
|
|
75
|
+
active = conversation['active_turn']
|
|
76
|
+
record = @store.get(SCOPE,key_for(data))
|
|
77
|
+
valid = task.status == :waiting && checkpoint.task_id == task.id && checkpoint.continuation &&
|
|
78
|
+
active && active.values_at('id','native_run_id','state') == [data['turn_id'],task.id,'running'] &&
|
|
79
|
+
record && record['native_run_id'] == task.id
|
|
80
|
+
raise StoreError, 'shared turn requires native reconciliation' unless valid
|
|
81
|
+
project(history_at(path,record['sequence'] - 1),path)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def complete(task:, messages:, pending_approval: false, output_parts: [], attachments: [])
|
|
85
|
+
data, = identity(task)
|
|
86
|
+
record = @store.get(SCOPE,key_for(data)) || raise(StoreError, 'shared admission missing')
|
|
87
|
+
raise StoreError, 'shared native execution differs' unless record['native_run_id'] == task.id
|
|
88
|
+
unless record['messages']
|
|
89
|
+
output = messages.drop_while { _1['role'] == 'user' }
|
|
90
|
+
raise StoreError, 'shared final answer missing' unless output.last && output.last['role'] == 'assistant'
|
|
91
|
+
record['pending_approval'] = pending_approval
|
|
92
|
+
record['messages'] = output.map do |message|
|
|
93
|
+
full = record.fetch('tool_results', {})[message['tool_call_id']]
|
|
94
|
+
if message['role'] == 'tool' && !full.nil?
|
|
95
|
+
text = full.is_a?(String) ? full : JSON.generate(full)
|
|
96
|
+
message = message.merge('content'=>text)
|
|
97
|
+
end
|
|
98
|
+
canonical(task, message)
|
|
99
|
+
end
|
|
100
|
+
record['output_parts'] = Array(output_parts)
|
|
101
|
+
record['delivery_attachments'] = Array(attachments)
|
|
102
|
+
record['uploads'] = Array(output_parts).map do |part|
|
|
103
|
+
require 'base64'
|
|
104
|
+
raise StoreError, 'invalid shared output media' unless %w[image audio].include?(part['type']) && part['base64'].is_a?(String)
|
|
105
|
+
bytes = Base64.strict_decode64(part['base64'])
|
|
106
|
+
raise StoreError, 'shared output exceeds 10 MiB' if bytes.bytesize > 10*1024*1024
|
|
107
|
+
id = SecureRandom.uuid
|
|
108
|
+
record['messages'].last['content'] << {'type'=>part['type'],'attachment_id'=>id}
|
|
109
|
+
{'id'=>id,'base64'=>part['base64'],'mime_type'=>part.fetch('mime_type'),'sha256'=>Digest::SHA256.hexdigest(bytes)}
|
|
110
|
+
end
|
|
111
|
+
@store.set(SCOPE,key_for(data),record)
|
|
112
|
+
end
|
|
113
|
+
flush(data,record)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def input_attachments(task:)
|
|
117
|
+
data, = identity(task)
|
|
118
|
+
return [] unless data['content']
|
|
119
|
+
project([{'role'=>'user', 'content'=>data['content']}],path_for(data)).first['attachments'].map { Media.hydrate_attachment(_1) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def record_tool_result(task:, call_id:, result:)
|
|
123
|
+
raise StoreError, 'shared tool correlation missing' if call_id.to_s.empty?
|
|
124
|
+
data, = identity(task)
|
|
125
|
+
@store.transaction do
|
|
126
|
+
record = @store.get(SCOPE,key_for(data)) || raise(StoreError, 'shared admission missing')
|
|
127
|
+
results = (record['tool_results'] ||= {})
|
|
128
|
+
# The first capture precedes prompt-only evidence reshaping and clipping.
|
|
129
|
+
results[call_id.to_s] ||= ToolTraceStore.mask(result.respond_to?(:content) ? result.content : result)
|
|
130
|
+
@store.set(SCOPE,key_for(data),record)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def identity(task)
|
|
135
|
+
payload = task.command.fetch('payload')
|
|
136
|
+
data = payload['shared_conversation']
|
|
137
|
+
raise StoreError, 'explicit shared conversation identity required' unless data.is_a?(Hash)
|
|
138
|
+
%w[tenant_id user_id agent_id conversation_id turn_id message_id].each do |name|
|
|
139
|
+
raise StoreError, "invalid shared #{name}" unless data[name].is_a?(String) && UUID.match?(data[name])
|
|
140
|
+
end
|
|
141
|
+
if data.key?('generation')
|
|
142
|
+
raise StoreError, 'invalid shared generation' unless data['generation'].is_a?(Integer) && data['generation'].positive?
|
|
143
|
+
else
|
|
144
|
+
record = @store.get(SCOPE,key_for(data))
|
|
145
|
+
data = data.merge('generation'=>record.fetch('generation')) if record
|
|
146
|
+
end
|
|
147
|
+
raise StoreError, 'original user_text required' unless payload['user_text'].is_a?(String)
|
|
148
|
+
content = data['content']
|
|
149
|
+
if content
|
|
150
|
+
valid = content.is_a?(Array) && content.all? do |part|
|
|
151
|
+
part.is_a?(Hash) && (part['type'] == 'text' ? part['text'].is_a?(String) :
|
|
152
|
+
%w[image audio document].include?(part['type']) && part['attachment_id'].is_a?(String) && UUID.match?(part['attachment_id']))
|
|
153
|
+
end
|
|
154
|
+
raise StoreError, 'invalid shared content' unless valid
|
|
155
|
+
raise StoreError, 'shared content differs from original speech' unless content.filter_map { _1['text'] }.join("\n") == payload['user_text']
|
|
156
|
+
end
|
|
157
|
+
if Media.parts(payload['parts']).any? { !_1.text? } && !Array(content).any? { _1['type'] != 'text' }
|
|
158
|
+
raise StoreError, 'upload media to central storage before admission'
|
|
159
|
+
end
|
|
160
|
+
tenant = task.command.fetch('meta')['tenant']
|
|
161
|
+
raise StoreError, 'shared tenant identity differs' unless tenant.to_s == data['tenant_id']
|
|
162
|
+
[data,payload]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def memory_context(task:, profile:)
|
|
166
|
+
data, payload = identity(task)
|
|
167
|
+
result = {}
|
|
168
|
+
if profile.memory
|
|
169
|
+
refs = request('GET', "/v1/memories/user/#{data['user_id']}?limit=50")['memories']
|
|
170
|
+
result['facts'] = refs.filter_map { memory_get(task:task,id:_1['id']) }
|
|
171
|
+
path = "/v1/memories/conversation/#{data['conversation_id']}"
|
|
172
|
+
request('GET',path+'?limit=50')['memories'].each do |ref|
|
|
173
|
+
record = request('GET',path+'/'+URI.encode_www_form_component(ref['id']),missing:true)
|
|
174
|
+
result['facts'] << record.merge('id'=>"conversation.#{record['id']}") if record
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
if Coercion.truthy?(profile.knowledge&.dig('retrieve'))
|
|
178
|
+
query = URI.encode_www_form_component(payload['user_text'][0, 200])
|
|
179
|
+
result['knowledge'] = request('GET', "/v1/memories/agent/#{data['agent_id']}?limit=5&query=#{query}")['memories']
|
|
180
|
+
end
|
|
181
|
+
result
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def memory_get(task:, id:, kind: 'fact', include_proposed: false)
|
|
185
|
+
data, = identity(task)
|
|
186
|
+
scope, owner = kind == 'knowledge' ? ['agent',data['agent_id']] : ['user',data['user_id']]
|
|
187
|
+
path = "/v1/memories/#{scope}/#{owner}/#{URI.encode_www_form_component(id)}"
|
|
188
|
+
path += '?include_proposed=true' if include_proposed
|
|
189
|
+
request('GET', path, missing:true)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def propose_memory(task:, id:, value:, kind: 'fact')
|
|
193
|
+
data, = identity(task)
|
|
194
|
+
scope, owner = kind == 'knowledge' ? ['agent',data['agent_id']] : ['user',data['user_id']]
|
|
195
|
+
current = memory_get(task:task,id:id,kind:kind,include_proposed:true)
|
|
196
|
+
recorded = @store.get(SCOPE,key_for(data))
|
|
197
|
+
source_ids = [data['message_id']] + Array(recorded && recorded['messages']).map { _1['id'] }
|
|
198
|
+
clean = value.is_a?(String) ? Safety::Detectors.redact(value).first : ToolTraceStore.mask(value)
|
|
199
|
+
request('PUT', "/v1/memories/#{scope}/#{owner}/#{URI.encode_www_form_component(id)}",
|
|
200
|
+
{'value'=>clean,'kind'=>kind,'origin'=>'insika','status'=>'proposed','expected_revision'=>current ? current['revision'] : 0,
|
|
201
|
+
'sources'=>source_ids.uniq.map { {'conversation_id'=>data['conversation_id'],'message_id'=>_1} }})
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def check_identity!(data, conversation, ownership: true)
|
|
207
|
+
unless conversation.values_at('tenant_id','user_id','agent_id') == data.values_at('tenant_id','user_id','agent_id') &&
|
|
208
|
+
(!ownership || conversation.values_at('assigned_harness','generation') == ['insika',data['generation']])
|
|
209
|
+
raise StoreError, 'shared conversation identity or generation mismatch'
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def key_for(data) = data.values_at('tenant_id','conversation_id','turn_id').join(':')
|
|
214
|
+
def path_for(data) = "/v1/conversations/#{data['conversation_id']}"
|
|
215
|
+
def input_digest(payload) = Digest::SHA256.hexdigest(JSON.generate(payload.slice('shared_conversation','user_text','origin')))
|
|
216
|
+
|
|
217
|
+
def canonical(task, message, id: SecureRandom.uuid)
|
|
218
|
+
result = {'schema_version'=>1,'id'=>id,'role'=>message.fetch('role'),
|
|
219
|
+
'content'=>[{'type'=>'text','text'=>message.fetch('content').to_s}],
|
|
220
|
+
'source'=>{'harness'=>'insika','session_id'=>task.session_id || task.id}}
|
|
221
|
+
result.merge!(ToolTraceStore.mask(message.slice('tool_calls','tool_call_id','origin','outcome')))
|
|
222
|
+
result
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def flush(data, record)
|
|
226
|
+
require 'base64'
|
|
227
|
+
record.fetch('uploads',[]).each do |upload|
|
|
228
|
+
request('PUT',path_for(data)+"/attachments/#{upload['id']}",Base64.strict_decode64(upload['base64']),
|
|
229
|
+
headers: {'Content-Type'=>upload['mime_type'],'X-Filename'=>upload['id'],
|
|
230
|
+
'X-Content-SHA256'=>upload['sha256'],'X-Conversation-Generation'=>data['generation'].to_s})
|
|
231
|
+
end
|
|
232
|
+
path = path_for(data)+"/turns/#{data['turn_id']}"
|
|
233
|
+
prefix = record.fetch('messages')[0...-1]
|
|
234
|
+
prefix.each_with_index do |message, index|
|
|
235
|
+
request('POST', path+'/messages', {'generation'=>data['generation'],'expected_sequence'=>record['sequence']+index,'messages'=>[message]})
|
|
236
|
+
end
|
|
237
|
+
request('POST', path+'/complete', {'generation'=>data['generation'],
|
|
238
|
+
'expected_sequence'=>record['sequence']+prefix.length,'message'=>record['messages'].last,'pending_approval'=>record.fetch('pending_approval',false)})
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def history_at(path, sequence)
|
|
242
|
+
messages, cursor = [], 0
|
|
243
|
+
while cursor < sequence
|
|
244
|
+
page = request('GET', path+"/messages?after_sequence=#{cursor}&through_sequence=#{sequence}&limit=10")
|
|
245
|
+
raise StoreError, 'shared history cursor did not advance' unless page['next_sequence'].is_a?(Integer) && page['next_sequence'] > cursor
|
|
246
|
+
messages.concat(page.fetch('messages'))
|
|
247
|
+
cursor = page['next_sequence']
|
|
248
|
+
end
|
|
249
|
+
messages
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def project(messages, path)
|
|
253
|
+
messages.map do |message|
|
|
254
|
+
text, attachments = [], []
|
|
255
|
+
message.fetch('content').each do |part|
|
|
256
|
+
if part['type'] == 'text'
|
|
257
|
+
text << part.fetch('text')
|
|
258
|
+
else
|
|
259
|
+
id = part.fetch('attachment_id')
|
|
260
|
+
raise StoreError, 'invalid shared attachment ID' unless UUID.match?(id)
|
|
261
|
+
bytes, headers = request('GET', path+"/attachments/#{id}", raw: true)
|
|
262
|
+
raise StoreError, 'shared attachment hash mismatch' unless Digest::SHA256.hexdigest(bytes) == headers['x-content-sha256']
|
|
263
|
+
require 'base64'
|
|
264
|
+
extension = {'image/png'=>'png','image/jpeg'=>'jpg','image/webp'=>'webp','image/gif'=>'gif',
|
|
265
|
+
'application/pdf'=>'pdf','audio/mpeg'=>'mp3','audio/wav'=>'wav','audio/ogg'=>'ogg','audio/webm'=>'webm','text/plain'=>'txt'}.fetch(headers['content-type'],'bin')
|
|
266
|
+
attachments << {'base64'=>Base64.strict_encode64(bytes),'filename'=>"#{id}.#{extension}"}
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
projected = message.slice('role','origin').merge('content'=>text.join("\n"),'attachments'=>attachments)
|
|
270
|
+
# RubyLLM checks answered call IDs across its entire chat, not per turn.
|
|
271
|
+
call_id = ->(id) { "history_#{Digest::SHA256.hexdigest("#{message.fetch('turn_id')}:#{id}")[0, 48]}" }
|
|
272
|
+
projected['tool_calls'] = message['tool_calls'].map { _1.merge('id'=>call_id.call(_1['id'])) } if message['tool_calls']
|
|
273
|
+
projected['tool_call_id'] = call_id.call(message['tool_call_id']) if message['tool_call_id']
|
|
274
|
+
projected
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def request(method, path, data = nil, raw: false, headers: {}, missing: false)
|
|
279
|
+
require 'net/http'
|
|
280
|
+
uri = @url.dup
|
|
281
|
+
uri.path = @url.path.delete_suffix('/') + path.split('?',2).first
|
|
282
|
+
uri.query = path.split('?',2)[1]
|
|
283
|
+
body = data.is_a?(String) ? data : data && JSON.generate(data)
|
|
284
|
+
raise StoreError, 'shared request exceeds 1 MiB' if body && !data.is_a?(String) && body.bytesize > 1_048_576
|
|
285
|
+
request = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
|
|
286
|
+
{'Authorization'=>"Bearer #{@token}", 'Content-Type'=>'application/json'}.merge(headers))
|
|
287
|
+
request.body = body if body
|
|
288
|
+
Net::HTTP.start(uri.host, uri.port, nil, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 10, write_timeout: 10) do |http|
|
|
289
|
+
http.max_retries = 0
|
|
290
|
+
http.request(request) do |response|
|
|
291
|
+
return nil if missing && response.code == '404'
|
|
292
|
+
raise StoreError, "shared conversation HTTP #{response.code}" unless response.code == '200'
|
|
293
|
+
bytes = +''.b
|
|
294
|
+
response.read_body do |chunk|
|
|
295
|
+
bytes << chunk
|
|
296
|
+
raise StoreError, 'shared response too large' if bytes.bytesize > (raw ? 10 : 16)*1024*1024
|
|
297
|
+
end
|
|
298
|
+
return raw ? [bytes,response.to_hash.transform_values(&:first)] : JSON.parse(bytes)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
rescue JSON::ParserError, IOError, SystemCallError, Timeout::Error, SocketError => error
|
|
302
|
+
raise StoreError, "shared conversation transport failed (#{error.class})"
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
data/lib/insika/skill_catalog.rb
CHANGED
|
@@ -28,10 +28,17 @@ module Insika
|
|
|
28
28
|
# store (optional): a SkillStore with the skills AUTHORED in the Studio.
|
|
29
29
|
# They overlay the on-disk ones (seed) — the Store wins, it is the source of truth.
|
|
30
30
|
# Nil = disk-only behavior, zero regression.
|
|
31
|
-
|
|
31
|
+
#
|
|
32
|
+
# recheck/clock: another worker may write a skill (pack import, Studio edit) and
|
|
33
|
+
# `reload` only runs in the process that handled the write. At most every
|
|
34
|
+
# `recheck` seconds the catalog compares the config version token and reloads
|
|
35
|
+
# when it moved — the same rule as OverlayToolRegistry for data-tools.
|
|
36
|
+
def initialize(roots, store: nil, recheck: 2, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
32
37
|
@roots = Array(roots)
|
|
33
38
|
@store = store
|
|
34
|
-
@
|
|
39
|
+
@recheck = recheck
|
|
40
|
+
@clock = clock
|
|
41
|
+
reload
|
|
35
42
|
end
|
|
36
43
|
|
|
37
44
|
# The SkillStore the catalog overlays — the composition root hands it to
|
|
@@ -49,6 +56,7 @@ module Insika
|
|
|
49
56
|
# Without `agent` the shared scope is all there is, which is what every caller
|
|
50
57
|
# that has no agent in hand (the Studio's shared editor, a bare catalog) means.
|
|
51
58
|
def all(agent: nil)
|
|
59
|
+
reload if stale?
|
|
52
60
|
shared = @skills
|
|
53
61
|
overrides = agent_scope(agent)
|
|
54
62
|
return shared.values if overrides.empty?
|
|
@@ -57,6 +65,7 @@ module Insika
|
|
|
57
65
|
end
|
|
58
66
|
|
|
59
67
|
def find(name, agent: nil)
|
|
68
|
+
reload if stale?
|
|
60
69
|
agent_scope(agent)[name.to_s] || @skills[name.to_s]
|
|
61
70
|
end
|
|
62
71
|
|
|
@@ -64,6 +73,8 @@ module Insika
|
|
|
64
73
|
# authored/edited skill takes effect without a restart. A turn in progress
|
|
65
74
|
# captured @skills at dispatch, so it does not see the swap mid-flight.
|
|
66
75
|
def reload
|
|
76
|
+
@token = version_token
|
|
77
|
+
@checked_at = @clock.call
|
|
67
78
|
@skills, @agent_skills = load_all
|
|
68
79
|
self
|
|
69
80
|
end
|
|
@@ -155,6 +166,21 @@ module Insika
|
|
|
155
166
|
# AgentProfile#stream_public?. Anything else (a list, nil, false) is not blanket.
|
|
156
167
|
def blanket?(spec) = Coercion.truthy?(spec)
|
|
157
168
|
|
|
169
|
+
# The token moves on any config write, so a reload may find nothing new. Read
|
|
170
|
+
# BEFORE load_all in `reload`: a write landing in between leaves the old token,
|
|
171
|
+
# and the next check reloads again instead of missing it.
|
|
172
|
+
def stale?
|
|
173
|
+
return false unless @store.respond_to?(:version_token)
|
|
174
|
+
|
|
175
|
+
now = @clock.call
|
|
176
|
+
return false if now - @checked_at < @recheck
|
|
177
|
+
|
|
178
|
+
@checked_at = now
|
|
179
|
+
version_token != @token
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def version_token = @store.respond_to?(:version_token) ? @store.version_token : nil
|
|
183
|
+
|
|
158
184
|
# An agent's override index; {} for a nil agent or one that specialized nothing.
|
|
159
185
|
def agent_scope(agent) = agent.nil? ? {} : (@agent_skills[agent.to_s] || {})
|
|
160
186
|
|
data/lib/insika/skill_store.rb
CHANGED
|
@@ -55,6 +55,9 @@ module Insika
|
|
|
55
55
|
names(agent: agent).each_with_object({}) { |n, acc| acc[n] = get(n, agent: agent) }
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
# Changes whenever any config is written, in any process (ConfigStore#version_token).
|
|
59
|
+
def version_token = @cs.version_token
|
|
60
|
+
|
|
58
61
|
# -> [String] every agent that has specialized at least one skill. What the
|
|
59
62
|
# catalog overlays and `doctor` sweeps.
|
|
60
63
|
def agents = @cs.keys(AGENT_SCOPE).sort
|
|
@@ -86,6 +89,9 @@ module Insika
|
|
|
86
89
|
# -> [ { "content" =>, "at" => } ] most recent first.
|
|
87
90
|
def versions(name, agent: nil) = record(name, agent)&.fetch("history", []) || []
|
|
88
91
|
|
|
92
|
+
# -> iso8601 of the current content's write, nil for an unknown skill.
|
|
93
|
+
def updated_at(name, agent: nil) = record(name, agent)&.fetch("updated_at", nil)
|
|
94
|
+
|
|
89
95
|
# Restores version `index` as the current content (a new write). -> Hash.
|
|
90
96
|
def restore(name, index, agent: nil)
|
|
91
97
|
hist = versions(name, agent: agent)
|
data/lib/insika/soak/runner.rb
CHANGED
|
@@ -6,17 +6,9 @@ module Insika
|
|
|
6
6
|
#
|
|
7
7
|
# A customer who corrects themselves while the agent is calling tools ("1234567",
|
|
8
8
|
# three seconds after "queria saber do pedido") should have that land before the
|
|
9
|
-
# model's next reasoning step, not after the whole run. RubyLLM
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# complete_once
|
|
14
|
-
# ├─ provider_completion → assistant message announcing N tool_calls
|
|
15
|
-
# ├─ after_message(assistant) ← N is read here
|
|
16
|
-
# └─ handle_tool_calls
|
|
17
|
-
# ├─ add_tool_result_message ×N
|
|
18
|
-
# │ └─ after_message(tool) ← counted; the Nth is THE BOUNDARY
|
|
19
|
-
# └─ halt_result || complete ← the next model step sees what we appended
|
|
9
|
+
# model's next reasoning step, not after the whole run. RubyLLM emits message
|
|
10
|
+
# callbacks during both `chat.ask` and native steps. ToolBatch finds the last
|
|
11
|
+
# sibling result so steering lands before the next model step.
|
|
20
12
|
#
|
|
21
13
|
# Counting to N is not an optimization, it is the correctness condition. A `user`
|
|
22
14
|
# message inserted BETWEEN tool results is rejected outright by Anthropic (all tool
|
|
@@ -41,9 +33,7 @@ module Insika
|
|
|
41
33
|
@actor = actor
|
|
42
34
|
@policy = policy
|
|
43
35
|
@emit = emit
|
|
44
|
-
@
|
|
45
|
-
@seen = 0
|
|
46
|
-
@halted = false
|
|
36
|
+
@batch = ToolBatch.new
|
|
47
37
|
@injected = 0
|
|
48
38
|
end
|
|
49
39
|
|
|
@@ -53,20 +43,13 @@ module Insika
|
|
|
53
43
|
# RubyLLM `after_tool_result`, with the RAW result — the only place a `Tool::Halt`
|
|
54
44
|
# is still recognizable. By the time it becomes a `role: tool` message its content
|
|
55
45
|
# is the payload, indistinguishable from an ordinary result.
|
|
56
|
-
def tool_result(result)
|
|
57
|
-
@halted = true if halt?(result)
|
|
58
|
-
end
|
|
46
|
+
def tool_result(result) = @batch.halt!(result)
|
|
59
47
|
|
|
60
48
|
# RubyLLM `after_message`. An assistant message carrying tool calls OPENS a batch;
|
|
61
49
|
# the Nth tool result CLOSES it, and that is the one boundary where appending is
|
|
62
50
|
# valid.
|
|
63
51
|
def message_ended(message)
|
|
64
|
-
|
|
65
|
-
return open_batch(message) if role == "assistant"
|
|
66
|
-
return unless role == "tool" && @expected
|
|
67
|
-
|
|
68
|
-
@seen += 1
|
|
69
|
-
inject! if @seen >= @expected
|
|
52
|
+
inject! if @batch.closed?(message)
|
|
70
53
|
end
|
|
71
54
|
|
|
72
55
|
# Tail-appends whatever is in the mailbox right now and reports how many. Called
|
|
@@ -88,34 +71,10 @@ module Insika
|
|
|
88
71
|
|
|
89
72
|
private
|
|
90
73
|
|
|
91
|
-
def open_batch(message)
|
|
92
|
-
calls = field(message, :tool_calls)
|
|
93
|
-
size = calls.respond_to?(:size) ? calls.size : 0
|
|
94
|
-
# A message with no tool call is the model talking, not a batch: leave any
|
|
95
|
-
# pending message where it is. The turn is about to end, and the Executor
|
|
96
|
-
# absorbs it there in ONE extra round (#absorb_pending!) — a text-only turn
|
|
97
|
-
# closes no batch, and answering it half-way is not an option.
|
|
98
|
-
return @expected = nil if size.zero?
|
|
99
|
-
|
|
100
|
-
@expected = size
|
|
101
|
-
@seen = 0
|
|
102
|
-
@halted = false
|
|
103
|
-
end
|
|
104
|
-
|
|
105
74
|
def inject!
|
|
106
|
-
@
|
|
107
|
-
return if @halted # nothing will read it: leave it in the mailbox
|
|
75
|
+
return if @batch.halted? # nothing will read it: leave it in the mailbox
|
|
108
76
|
|
|
109
77
|
absorb_pending!
|
|
110
78
|
end
|
|
111
|
-
|
|
112
|
-
def halt?(result) = defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
|
|
113
|
-
|
|
114
|
-
def field(message, name)
|
|
115
|
-
return message.public_send(name) if message.respond_to?(name)
|
|
116
|
-
return message[name] || message[name.to_s] if message.respond_to?(:[])
|
|
117
|
-
|
|
118
|
-
nil
|
|
119
|
-
end
|
|
120
79
|
end
|
|
121
80
|
end
|
data/lib/insika/store.rb
CHANGED
|
@@ -25,7 +25,9 @@ module Insika
|
|
|
25
25
|
#
|
|
26
26
|
# Backends `include Store` and override the six methods; any forgotten
|
|
27
27
|
# method raises NotImplementedError (fail-fast, better than a distant
|
|
28
|
-
# NoMethodError).
|
|
28
|
+
# NoMethodError). #entries, #recent and #amend have defaults; a backend that
|
|
29
|
+
# knows its write order overrides #recent and #amend together (the contract
|
|
30
|
+
# suite checks that an amended key keeps its place in #recent).
|
|
29
31
|
module Store
|
|
30
32
|
# -> Object | nil (deserialized)
|
|
31
33
|
def get(scope, key)
|
|
@@ -37,6 +39,13 @@ module Insika
|
|
|
37
39
|
raise NotImplementedError, "#{self.class}#set"
|
|
38
40
|
end
|
|
39
41
|
|
|
42
|
+
# -> value. Like #set, but an existing key keeps its place in #recent: an edit
|
|
43
|
+
# that is not new activity (a review on a session). This default is #set,
|
|
44
|
+
# exact only for a backend whose #recent reads the value's "updated_at".
|
|
45
|
+
def amend(scope, key, value)
|
|
46
|
+
set(scope, key, value)
|
|
47
|
+
end
|
|
48
|
+
|
|
40
49
|
# -> true | false (did it exist?)
|
|
41
50
|
def delete(scope, key)
|
|
42
51
|
raise NotImplementedError, "#{self.class}#delete"
|
|
@@ -53,6 +62,25 @@ module Insika
|
|
|
53
62
|
raise NotImplementedError, "#{self.class}#scopes"
|
|
54
63
|
end
|
|
55
64
|
|
|
65
|
+
# -> [[key, value], ...] sorted by key, filtered like #list. The bulk read for
|
|
66
|
+
# a caller that lists keys and reads each one. This default is exactly that;
|
|
67
|
+
# a backend overrides it to answer in one query (no lock taken, like #list).
|
|
68
|
+
def entries(scope, prefix = nil)
|
|
69
|
+
list(scope, prefix).filter_map do |key|
|
|
70
|
+
value = get(scope, key)
|
|
71
|
+
[key, value] unless value.nil?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# -> [[key, value], ...] the `limit` most recently WRITTEN keys of the scope
|
|
76
|
+
# (filtered like #list), newest first: a "recent" list without reading the
|
|
77
|
+
# whole scope. A backend that knows its write order answers it directly;
|
|
78
|
+
# this default approximates it by each value's "updated_at", when it has one.
|
|
79
|
+
# `offset` skips that many of the newest (the next page).
|
|
80
|
+
def recent(scope, prefix, limit, offset = 0)
|
|
81
|
+
entries(scope, prefix).sort_by { |_, v| v.is_a?(Hash) ? v["updated_at"].to_s : "" }.reverse.drop(offset).first(limit)
|
|
82
|
+
end
|
|
83
|
+
|
|
56
84
|
# -> the block's result; atomic if the backend supports it
|
|
57
85
|
def transaction(&blk)
|
|
58
86
|
raise NotImplementedError, "#{self.class}#transaction"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Stores
|
|
7
|
+
# The type model every backend shares: JSON types plus Symbol (stored as
|
|
8
|
+
# String). Anything else is refused at the write (fail-fast), so no backend
|
|
9
|
+
# ever stores garbage.
|
|
10
|
+
#
|
|
11
|
+
# Does not use `JSON.generate(strict: true)`: under older json versions
|
|
12
|
+
# `strict` rejects Symbol, which would break the Symbol coercion. The
|
|
13
|
+
# explicit check is independent of the json version.
|
|
14
|
+
module JsonValue
|
|
15
|
+
JSONABLE = [NilClass, TrueClass, FalseClass, Integer, Float, String, Symbol].freeze
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def serialize(value)
|
|
20
|
+
ensure_jsonable!(value)
|
|
21
|
+
JSON.generate(value)
|
|
22
|
+
rescue JSON::GeneratorError => e
|
|
23
|
+
raise Insika::StoreError, "value not serializable: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ensure_jsonable!(value)
|
|
27
|
+
case value
|
|
28
|
+
when *JSONABLE then nil
|
|
29
|
+
when Array then value.each { |v| ensure_jsonable!(v) }
|
|
30
|
+
when Hash then value.each { |k, v| ensure_jsonable!(k); ensure_jsonable!(v) }
|
|
31
|
+
else
|
|
32
|
+
raise Insika::StoreError, "value not serializable: #{value.class} not allowed in JSON"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|