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,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# OpenClaw / AgentSkills convention: each skill is a directory with a
|
|
7
|
+
# SKILL.md (YAML frontmatter + markdown body). Progressive disclosure:
|
|
8
|
+
# level 1 = name+description in the system prompt; level 2 = body loaded
|
|
9
|
+
# on demand by the load_skill tool.
|
|
10
|
+
#
|
|
11
|
+
# Consumed by the Executor (skill_catalog:) and by stage 3
|
|
12
|
+
# (effective/format_for_prompt).
|
|
13
|
+
class SkillCatalog
|
|
14
|
+
# Eagerness is NOT here. It used to be a frontmatter flag, i.e. a property of the
|
|
15
|
+
# SKILL — but skills are shared between agents, so one flag forced one decision
|
|
16
|
+
# onto every allowlist holding the skill. It is a property of the AGENT
|
|
17
|
+
# (`profile.skills_eager`, see #eager_for).
|
|
18
|
+
#
|
|
19
|
+
# companions: names of the skills this one cannot work without. Injecting or
|
|
20
|
+
# loading a skill brings them along, so the half-recipe state cannot be assembled —
|
|
21
|
+
# a reference table arriving without the procedure that reads it is worse than
|
|
22
|
+
# nothing, because the model then never asks for the other half.
|
|
23
|
+
Skill = Data.define(:name, :description, :path, :body, :triggers, :companions)
|
|
24
|
+
|
|
25
|
+
# roots ordered by PRECEDENCE (highest first): workspace, managed,
|
|
26
|
+
# bundled. Same name in more than one root: the first wins.
|
|
27
|
+
#
|
|
28
|
+
# store (optional): a SkillStore with the skills AUTHORED in the Studio.
|
|
29
|
+
# They overlay the on-disk ones (seed) — the Store wins, it is the source of truth.
|
|
30
|
+
# Nil = disk-only behavior, zero regression.
|
|
31
|
+
def initialize(roots, store: nil)
|
|
32
|
+
@roots = Array(roots)
|
|
33
|
+
@store = store
|
|
34
|
+
@skills, @agent_skills = load_all
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# `agent` (an agent id) resolves the AGENT SCOPE first, then the shared one — the
|
|
38
|
+
# same precedence chain the catalog already runs for store-over-disk and
|
|
39
|
+
# workspace-over-managed-over-bundled, with one more dimension.
|
|
40
|
+
#
|
|
41
|
+
# Three cases fall out of that one rule: SHARED (only the shared record exists),
|
|
42
|
+
# OVERRIDE (both exist, the agent's wins) and AGENT-PRIVATE (only the agent record
|
|
43
|
+
# exists — invisible elsewhere, and its name may collide freely).
|
|
44
|
+
#
|
|
45
|
+
# Without `agent` the shared scope is all there is, which is what every caller
|
|
46
|
+
# that has no agent in hand (the Studio's shared editor, a bare catalog) means.
|
|
47
|
+
def all(agent: nil)
|
|
48
|
+
shared = @skills
|
|
49
|
+
overrides = agent_scope(agent)
|
|
50
|
+
return shared.values if overrides.empty?
|
|
51
|
+
|
|
52
|
+
shared.merge(overrides).values
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def find(name, agent: nil)
|
|
56
|
+
agent_scope(agent)[name.to_s] || @skills[name.to_s]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Reloads from disk + Store and SWAPS the index atomically: an
|
|
60
|
+
# authored/edited skill takes effect without a restart. A turn in progress
|
|
61
|
+
# captured @skills at dispatch, so it does not see the swap mid-flight.
|
|
62
|
+
def reload
|
|
63
|
+
@skills, @agent_skills = load_all
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Per-agent allowlist: nil -> all | [] -> none | [names] -> subset. `agent`
|
|
68
|
+
# selects WHICH body each allowed name resolves to (see #find); the allowlist is
|
|
69
|
+
# by NAME either way, so specializing a skill never touches the allowlist.
|
|
70
|
+
def effective(skills_policy, agent: nil)
|
|
71
|
+
Allowlist.filter(all(agent: agent), skills_policy) { |s| s.name }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# THE single definition of "always in the prompt", consulted by all three
|
|
75
|
+
# surfaces that must agree: the body provider (injects these), the level-1
|
|
76
|
+
# catalog (hides them) and load_skill (refuses them). Split the rule across three
|
|
77
|
+
# files and they drift — which is the failure this whole feature came from.
|
|
78
|
+
#
|
|
79
|
+
# `profile.skills_eager` — a PER-AGENT decision, so a shared skill stays shared:
|
|
80
|
+
# nil | false -> none (progressive disclosure; the default)
|
|
81
|
+
# true -> every allowed skill (blanket; only for a corpus that fits the budget)
|
|
82
|
+
# [names] -> exactly these
|
|
83
|
+
#
|
|
84
|
+
# Deliberately NOT `Allowlist.filter`: there nil means ALL, which is the safe
|
|
85
|
+
# default for `skills`/`tools_allow` where nil is "no policy". Here nil must mean
|
|
86
|
+
# NONE — an unconfigured agent waking up with every skill body on every turn is
|
|
87
|
+
# the opposite of a safe default. A name that is not in the agent's `skills`
|
|
88
|
+
# allowlist is a silent no-op here (the intersection with `effective`); `doctor`
|
|
89
|
+
# flags it, because the operator who wrote the name meant it.
|
|
90
|
+
def eager_for(profile)
|
|
91
|
+
allowed = effective(profile.skills, agent: profile.id)
|
|
92
|
+
spec = profile.skills_eager
|
|
93
|
+
return allowed if blanket?(spec)
|
|
94
|
+
return [] if spec.nil? || spec == false
|
|
95
|
+
|
|
96
|
+
names = Array(spec).map { |n| n.to_s.strip }
|
|
97
|
+
allowed.select { |s| names.include?(s.name) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The complement: what the model still has to ASK for — and therefore what the
|
|
101
|
+
# level-1 list advertises and load_skill will serve.
|
|
102
|
+
def lazy_for(profile) = effective(profile.skills, agent: profile.id) - eager_for(profile)
|
|
103
|
+
|
|
104
|
+
# Level 1: compact list injected into the system prompt. Metadata only.
|
|
105
|
+
# Receives the set already filtered by the agent.
|
|
106
|
+
#
|
|
107
|
+
# `when=` carries the skill's `triggers:` — THE ROUTING TABLE, GENERATED. What
|
|
108
|
+
# actually made activation reliable on the pilot was a hand-written companion file
|
|
109
|
+
# listing each skill with its trigger phrases, and nothing checked it against the
|
|
110
|
+
# catalog: a skill created at 11:28 was invisible to a table written the day
|
|
111
|
+
# before, and the model obeyed the table. Rendering the same information from the
|
|
112
|
+
# catalog means it cannot disagree with the allowlist — a newly allowed skill
|
|
113
|
+
# appears the moment it is allowed. Detecting that drift would have been strictly
|
|
114
|
+
# worse than removing its source.
|
|
115
|
+
def format_for_prompt(skills = all)
|
|
116
|
+
return "" if skills.empty?
|
|
117
|
+
|
|
118
|
+
entries = skills.map do |s|
|
|
119
|
+
when_attr = Array(s.triggers).empty? ? "" : %( when="#{Array(s.triggers).join('; ')}")
|
|
120
|
+
%( <skill name="#{s.name}"#{when_attr}>#{s.description}</skill>)
|
|
121
|
+
end.join("\n")
|
|
122
|
+
|
|
123
|
+
<<~PROMPT.strip
|
|
124
|
+
<available_skills>
|
|
125
|
+
#{entries}
|
|
126
|
+
</available_skills>
|
|
127
|
+
|
|
128
|
+
Before ANY reply or tool call: scan the skills above. If one matches
|
|
129
|
+
or is even partially relevant to the task, you MUST call
|
|
130
|
+
`load_skill("name")` FIRST and follow what it returns. Err on the
|
|
131
|
+
side of loading. Only skip when genuinely none apply.
|
|
132
|
+
PROMPT
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
# The blanket switch, tolerant of the strings a form / JSON round-trip produces
|
|
138
|
+
# ("1" from a checkbox, "true" from a pack) — same reading as
|
|
139
|
+
# AgentProfile#stream_public?. Anything else (a list, nil, false) is not blanket.
|
|
140
|
+
def blanket?(spec) = Coercion.truthy?(spec)
|
|
141
|
+
|
|
142
|
+
# An agent's override index; {} for a nil agent or one that specialized nothing.
|
|
143
|
+
def agent_scope(agent) = agent.nil? ? {} : (@agent_skills[agent.to_s] || {})
|
|
144
|
+
|
|
145
|
+
# -> [shared index, { agent_id => index }].
|
|
146
|
+
def load_all
|
|
147
|
+
found = {}
|
|
148
|
+
@roots.each do |root|
|
|
149
|
+
Dir.glob(File.join(root, "**", "SKILL.md")).sort.each do |file|
|
|
150
|
+
skill = parse_content(File.read(file, encoding: "UTF-8"), path: file)
|
|
151
|
+
next unless skill
|
|
152
|
+
|
|
153
|
+
found[skill.name] ||= skill # precedence: first root wins
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
overlay_store(found)
|
|
157
|
+
[found, load_agent_scopes]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Store skills overlay the on-disk ones (authored > seed). Sentinel path
|
|
161
|
+
# "store:<name>" — not a real file (load_skill uses `body`, not the path).
|
|
162
|
+
def overlay_store(found)
|
|
163
|
+
return unless @store
|
|
164
|
+
|
|
165
|
+
@store.all.each do |name, content|
|
|
166
|
+
skill = parse_content(content.to_s, path: "store:#{name}", key: name)
|
|
167
|
+
found[name.to_s] = skill if skill # Store wins
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Per-agent overrides / private skills, one index per agent. A store that predates
|
|
172
|
+
# the agent dimension answers nothing here, so this is {} and every lookup falls
|
|
173
|
+
# straight through to the shared scope.
|
|
174
|
+
def load_agent_scopes
|
|
175
|
+
return {} unless @store.respond_to?(:agents)
|
|
176
|
+
|
|
177
|
+
@store.agents.each_with_object({}) do |agent, acc|
|
|
178
|
+
index = {}
|
|
179
|
+
@store.all(agent: agent).each do |name, content|
|
|
180
|
+
skill = parse_content(content.to_s, path: "store:#{agent}/#{name}", key: name)
|
|
181
|
+
index[name.to_s] = skill if skill
|
|
182
|
+
end
|
|
183
|
+
acc[agent.to_s] = index unless index.empty?
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# `key` = THE STORE POSITION, and it wins over the frontmatter `name:`. An override
|
|
188
|
+
# authored for one agent still says `name: escalation-to-human` inside — that is
|
|
189
|
+
# deliberate, it is the same skill specialized — and indexing by the parsed name
|
|
190
|
+
# would clobber the shared record globally, which is the exact bug the agent scope
|
|
191
|
+
# exists to fix. It also makes a pack whose directory name and frontmatter name
|
|
192
|
+
# disagree resolvable: the allowlist is written from the directory.
|
|
193
|
+
def parse_content(raw, path:, key: nil)
|
|
194
|
+
match = raw.match(/\A---\s*\n(.*?)\n---\s*\n(.*)\z/m)
|
|
195
|
+
return nil unless match
|
|
196
|
+
|
|
197
|
+
# Tolerant frontmatter: real packs have `: ` in the description prose, which
|
|
198
|
+
# strict YAML rejected (the pack would not load).
|
|
199
|
+
meta = Insika::Frontmatter.parse(match[1])
|
|
200
|
+
name = key || meta["name"]
|
|
201
|
+
return nil unless name && Coercion.present?(meta["name"])
|
|
202
|
+
|
|
203
|
+
Skill.new(
|
|
204
|
+
name: name.to_s,
|
|
205
|
+
description: meta["description"].to_s,
|
|
206
|
+
path: path,
|
|
207
|
+
body: match[2].strip,
|
|
208
|
+
triggers: parse_list(meta["triggers"]),
|
|
209
|
+
companions: parse_list(meta["companions"])
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# `triggers:` / `companions:` frontmatter. YAML list, or comma-separated string
|
|
214
|
+
# under the lenient parse (which yields the whole value as one String).
|
|
215
|
+
def parse_list(raw)
|
|
216
|
+
list = raw.is_a?(String) ? raw.split(",") : Array(raw)
|
|
217
|
+
list.map { |t| t.to_s.strip }.reject(&:empty?)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# AUTHORED skills, in two scopes.
|
|
7
|
+
# Holds the complete SKILL.md (frontmatter + body) in the durable Store. The
|
|
8
|
+
# SkillCatalog overlays these skills on top of the on-disk ones (seed), with the Store
|
|
9
|
+
# winning — so editing/creating a skill in the Studio takes effect without a restart (via reload).
|
|
10
|
+
#
|
|
11
|
+
# SHARED scope (`agent:` omitted) — one record per skill in the ConfigStore
|
|
12
|
+
# (scope "skills"), keyed by the skill name:
|
|
13
|
+
# { "content" => "<entire SKILL.md>",
|
|
14
|
+
# "updated_at" => iso8601,
|
|
15
|
+
# "history" => [ { "content" =>, "at" => }, ... ] }
|
|
16
|
+
#
|
|
17
|
+
# AGENT scope (`agent:` given) — one record per AGENT (scope "agent_skills"), the
|
|
18
|
+
# skills nested under it, exactly the AgentFileStore shape:
|
|
19
|
+
# { "skills" => { "<name>" => { "content" =>, "updated_at" =>, "history" => [] } } }
|
|
20
|
+
#
|
|
21
|
+
# The agent dimension is a SECOND ARGUMENT, never part of the key. A composite
|
|
22
|
+
# `"agent/name"` key would put a `/` inside what the Studio serves as a single path
|
|
23
|
+
# segment (`GET /skills/:name`, the editor, the versions list) — the class of route
|
|
24
|
+
# bug that ships green and 404s in production. Two arguments become two route
|
|
25
|
+
# segments (`/agents/:id/skills/:name`) and nothing needs encoding.
|
|
26
|
+
#
|
|
27
|
+
# Two scopes and not one: the shared records are untouched by the arrival of the
|
|
28
|
+
# agent dimension, so there is no migration and a live deployment keeps serving
|
|
29
|
+
# exactly what it served.
|
|
30
|
+
#
|
|
31
|
+
# THE STORE POSITION IS THE IDENTITY. Which scope a record sits in — and under which
|
|
32
|
+
# key — is what decides which skill it is; the frontmatter `name:` inside an override
|
|
33
|
+
# stays the bare shared name. See SkillCatalog#find.
|
|
34
|
+
class SkillStore
|
|
35
|
+
SCOPE = "skills"
|
|
36
|
+
AGENT_SCOPE = "agent_skills"
|
|
37
|
+
HISTORY_MAX = 20
|
|
38
|
+
|
|
39
|
+
def initialize(config_store:)
|
|
40
|
+
@cs = config_store
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# -> String | nil (complete SKILL.md).
|
|
44
|
+
def get(name, agent: nil)
|
|
45
|
+
record(name, agent)&.fetch("content", nil)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# -> [String] names in the scope, lexicographic order.
|
|
49
|
+
def names(agent: nil)
|
|
50
|
+
agent.nil? ? @cs.keys(SCOPE) : agent_skills(agent).keys.sort
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# -> { name => content } of the scope's authored skills.
|
|
54
|
+
def all(agent: nil)
|
|
55
|
+
names(agent: agent).each_with_object({}) { |n, acc| acc[n] = get(n, agent: agent) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# -> [String] every agent that has specialized at least one skill. What the
|
|
59
|
+
# catalog overlays and `doctor` sweeps.
|
|
60
|
+
def agents = @cs.keys(AGENT_SCOPE).sort
|
|
61
|
+
|
|
62
|
+
# Writes (upsert). create_only refuses to overwrite. -> Hash (the stored record).
|
|
63
|
+
def write(name, content, agent: nil, create_only: false)
|
|
64
|
+
key = name.to_s
|
|
65
|
+
current = record(key, agent)
|
|
66
|
+
raise Insika::ValidationError, "skill '#{key}' already exists#{" for agent '#{agent}'" if agent}" if create_only && current
|
|
67
|
+
|
|
68
|
+
rec = build_record(content.to_s, current)
|
|
69
|
+
put(key, rec, agent)
|
|
70
|
+
rec
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# -> bool (did it exist?).
|
|
74
|
+
def delete(name, agent: nil)
|
|
75
|
+
key = name.to_s
|
|
76
|
+
return @cs.delete(SCOPE, key) if agent.nil?
|
|
77
|
+
|
|
78
|
+
wrapper = @cs.get(AGENT_SCOPE, agent.to_s)
|
|
79
|
+
return false unless wrapper&.dig("skills", key)
|
|
80
|
+
|
|
81
|
+
wrapper["skills"].delete(key)
|
|
82
|
+
@cs.put(AGENT_SCOPE, agent.to_s, wrapper)
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# -> [ { "content" =>, "at" => } ] most recent first.
|
|
87
|
+
def versions(name, agent: nil) = record(name, agent)&.fetch("history", []) || []
|
|
88
|
+
|
|
89
|
+
# Restores version `index` as the current content (a new write). -> Hash.
|
|
90
|
+
def restore(name, index, agent: nil)
|
|
91
|
+
hist = versions(name, agent: agent)
|
|
92
|
+
i = Integer(index)
|
|
93
|
+
raise Insika::NotFoundError, "skill '#{name}' not found" unless record(name.to_s, agent)
|
|
94
|
+
raise Insika::ValidationError, "version #{index} does not exist" if i.negative? || i >= hist.length
|
|
95
|
+
|
|
96
|
+
write(name, hist[i]["content"], agent: agent)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def record(name, agent)
|
|
102
|
+
return @cs.get(SCOPE, name.to_s) if agent.nil?
|
|
103
|
+
|
|
104
|
+
agent_skills(agent)[name.to_s]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def put(key, rec, agent)
|
|
108
|
+
return @cs.put(SCOPE, key, rec) if agent.nil?
|
|
109
|
+
|
|
110
|
+
wrapper = @cs.get(AGENT_SCOPE, agent.to_s) || { "skills" => {} }
|
|
111
|
+
wrapper["skills"] ||= {}
|
|
112
|
+
wrapper["skills"][key] = rec
|
|
113
|
+
@cs.put(AGENT_SCOPE, agent.to_s, wrapper)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def agent_skills(agent) = (@cs.get(AGENT_SCOPE, agent.to_s) || {})["skills"] || {}
|
|
117
|
+
|
|
118
|
+
def build_record(content, current)
|
|
119
|
+
history = current ? current.fetch("history", []) : []
|
|
120
|
+
if current
|
|
121
|
+
history = [{ "content" => current["content"], "at" => current["updated_at"] }] + history
|
|
122
|
+
history = history.first(HISTORY_MAX)
|
|
123
|
+
end
|
|
124
|
+
{ "content" => content, "updated_at" => Time.now.utc.iso8601, "history" => history }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# WHERE a message that arrived mid-run is allowed to enter the
|
|
5
|
+
# conversation.
|
|
6
|
+
#
|
|
7
|
+
# A customer who corrects themselves while the agent is calling tools ("1234567",
|
|
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 runs the entire tool
|
|
10
|
+
# loop inside `chat.ask`, so the only place to append is from inside its callbacks —
|
|
11
|
+
# which is enough, because they are public and additive:
|
|
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
|
|
20
|
+
#
|
|
21
|
+
# Counting to N is not an optimization, it is the correctness condition. A `user`
|
|
22
|
+
# message inserted BETWEEN tool results is rejected outright by Anthropic (all tool
|
|
23
|
+
# results of a batch must sit together) and merely tolerated by OpenAI.
|
|
24
|
+
#
|
|
25
|
+
# Two invariants this object exists to keep:
|
|
26
|
+
#
|
|
27
|
+
# · **Tail-append only.** Nothing already sent to the provider is edited, reordered
|
|
28
|
+
# or removed. That is what keeps the prompt cache valid, and a cache miss on a
|
|
29
|
+
# ~48k-token identity is a real cost, not a theoretical one.
|
|
30
|
+
# · **A halted batch injects nothing.** With `halt_when` there is no next model step
|
|
31
|
+
# (`handle_tool_calls` returns the Halt), so an appended message would sit in the
|
|
32
|
+
# transcript unanswered forever. The messages stay in the mailbox and the Executor
|
|
33
|
+
# releases them as a follow-up turn.
|
|
34
|
+
class SteerInjector
|
|
35
|
+
# chat: the turn's RubyLLM::Chat (already assembled).
|
|
36
|
+
# actor: the turn's TaskActor — the mailbox the steered messages arrive in.
|
|
37
|
+
# policy: the turn's QueuePolicy (`frame` decides how the text is worded).
|
|
38
|
+
# emit: ->(type, data) — the Executor's emitter, already bound to the task.
|
|
39
|
+
def initialize(chat:, actor:, policy:, emit:)
|
|
40
|
+
@chat = chat
|
|
41
|
+
@actor = actor
|
|
42
|
+
@policy = policy
|
|
43
|
+
@emit = emit
|
|
44
|
+
@expected = nil # tool calls announced by the batch in flight (nil = not in one)
|
|
45
|
+
@seen = 0
|
|
46
|
+
@halted = false
|
|
47
|
+
@injected = 0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# How many messages this run absorbed (read by specs and by the turn's event).
|
|
51
|
+
attr_reader :injected
|
|
52
|
+
|
|
53
|
+
# RubyLLM `after_tool_result`, with the RAW result — the only place a `Tool::Halt`
|
|
54
|
+
# is still recognizable. By the time it becomes a `role: tool` message its content
|
|
55
|
+
# is the payload, indistinguishable from an ordinary result.
|
|
56
|
+
def tool_result(result)
|
|
57
|
+
@halted = true if halt?(result)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# RubyLLM `after_message`. An assistant message carrying tool calls OPENS a batch;
|
|
61
|
+
# the Nth tool result CLOSES it, and that is the one boundary where appending is
|
|
62
|
+
# valid.
|
|
63
|
+
def message_ended(message)
|
|
64
|
+
role = field(message, :role).to_s
|
|
65
|
+
return open_batch(message) if role == "assistant"
|
|
66
|
+
return unless role == "tool" && @expected
|
|
67
|
+
|
|
68
|
+
@seen += 1
|
|
69
|
+
inject! if @seen >= @expected
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def open_batch(message)
|
|
75
|
+
calls = field(message, :tool_calls)
|
|
76
|
+
size = calls.respond_to?(:size) ? calls.size : 0
|
|
77
|
+
# A message with no tool call is the model talking, not a batch: leave any
|
|
78
|
+
# pending message where it is (the turn is about to end, and the Executor
|
|
79
|
+
# releases it as a follow-up turn rather than answering it half-way).
|
|
80
|
+
return @expected = nil if size.zero?
|
|
81
|
+
|
|
82
|
+
@expected = size
|
|
83
|
+
@seen = 0
|
|
84
|
+
@halted = false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def inject!
|
|
88
|
+
@expected = nil
|
|
89
|
+
return if @halted # nothing will read it: leave it in the mailbox
|
|
90
|
+
|
|
91
|
+
texts = @actor.take_user_messages!
|
|
92
|
+
return if texts.empty?
|
|
93
|
+
|
|
94
|
+
texts.each { |text| @chat.add_message(role: :user, content: @policy.frame(text)) }
|
|
95
|
+
@injected += texts.size
|
|
96
|
+
# Counts only, never content — the text is already in the transcript, which is the
|
|
97
|
+
# surface that is allowed to carry it. `task_id`/`session_id` are the event's meta.
|
|
98
|
+
@emit.call(:turn_steered, { count: texts.size, total: @injected })
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def halt?(result) = defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
|
|
102
|
+
|
|
103
|
+
def field(message, name)
|
|
104
|
+
return message.public_send(name) if message.respond_to?(name)
|
|
105
|
+
return message[name] || message[name.to_s] if message.respond_to?(:[])
|
|
106
|
+
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/insika/store.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Minimal persistence contract.
|
|
5
|
+
# Namespace-scoped KV, transactional when the backend supports it.
|
|
6
|
+
# Every implementation passes the SAME contract suite
|
|
7
|
+
# (lib/insika/testing/store_contract.rb — requirable from outside the repo,
|
|
8
|
+
# Values must be JSON-serializable.
|
|
9
|
+
#
|
|
10
|
+
# scope: String — separates domains/tenants (e.g. "sessions", "tasks:tenant_x")
|
|
11
|
+
# key: Hierarchical String (e.g. "task:123", "checkpoint:123:turn:4")
|
|
12
|
+
#
|
|
13
|
+
# Contract rules (verified by the suite):
|
|
14
|
+
# - get on a nonexistent key -> nil (never an exception)
|
|
15
|
+
# - set overwrites silently (last-write-wins)
|
|
16
|
+
# - round-trip preserves JSON types; Symbols become Strings (the domain
|
|
17
|
+
# normalizes at the boundary)
|
|
18
|
+
# - list(scope) returns only keys of the scope, ordered lexicographically;
|
|
19
|
+
# prefix filters by start_with?
|
|
20
|
+
# - a nested transaction reuses the outer transaction (no SAVEPOINT)
|
|
21
|
+
# - a serialization failure on write -> Insika::StoreError (fail-fast)
|
|
22
|
+
#
|
|
23
|
+
# Backends `include Store` and override the five methods; any forgotten
|
|
24
|
+
# method raises NotImplementedError (fail-fast, better than a distant
|
|
25
|
+
# NoMethodError).
|
|
26
|
+
module Store
|
|
27
|
+
# -> Object | nil (deserialized)
|
|
28
|
+
def get(scope, key)
|
|
29
|
+
raise NotImplementedError, "#{self.class}#get"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# -> value (the SAME object passed in, not the round-trip)
|
|
33
|
+
def set(scope, key, value)
|
|
34
|
+
raise NotImplementedError, "#{self.class}#set"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# -> true | false (did it exist?)
|
|
38
|
+
def delete(scope, key)
|
|
39
|
+
raise NotImplementedError, "#{self.class}#delete"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# -> [String] keys ordered lexicographically
|
|
43
|
+
def list(scope, prefix = nil)
|
|
44
|
+
raise NotImplementedError, "#{self.class}#list"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# -> the block's result; atomic if the backend supports it
|
|
48
|
+
def transaction(&blk)
|
|
49
|
+
raise NotImplementedError, "#{self.class}#transaction"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Stores
|
|
7
|
+
# In-memory backend for dev/test.
|
|
8
|
+
# Serializes JSON even in memory: exact parity of type semantics
|
|
9
|
+
# with SQLite — the contract suite is honest.
|
|
10
|
+
# No lock: cooperative fibers do not preempt in the middle of a Hash
|
|
11
|
+
# operation.
|
|
12
|
+
class Memory
|
|
13
|
+
include Store
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@data = new_store
|
|
17
|
+
@tx_depth = 0
|
|
18
|
+
@snapshot = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def get(scope, key)
|
|
22
|
+
raw = @data[scope][key]
|
|
23
|
+
return nil if raw.nil?
|
|
24
|
+
|
|
25
|
+
JSON.parse(raw)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def set(scope, key, value)
|
|
29
|
+
@data[scope][key] = serialize(value)
|
|
30
|
+
value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def delete(scope, key)
|
|
34
|
+
!@data[scope].delete(key).nil?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def list(scope, prefix = nil)
|
|
38
|
+
keys = @data[scope].keys.sort
|
|
39
|
+
prefix ? keys.select { |k| k.start_with?(prefix) } : keys
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Snapshot at the start of the outermost transaction; an exception at any
|
|
43
|
+
# level -> restore the snapshot and re-propagate (a REAL rollback).
|
|
44
|
+
# A nested one reuses the outer (no SAVEPOINT).
|
|
45
|
+
def transaction
|
|
46
|
+
if @tx_depth.positive?
|
|
47
|
+
@tx_depth += 1
|
|
48
|
+
begin
|
|
49
|
+
return yield
|
|
50
|
+
ensure
|
|
51
|
+
@tx_depth -= 1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@snapshot = deep_snapshot
|
|
56
|
+
@tx_depth = 1
|
|
57
|
+
begin
|
|
58
|
+
yield
|
|
59
|
+
rescue StandardError
|
|
60
|
+
restore_snapshot
|
|
61
|
+
raise
|
|
62
|
+
ensure
|
|
63
|
+
@tx_depth = 0
|
|
64
|
+
@snapshot = nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# JSON model types + Symbol (coerced to String on write).
|
|
69
|
+
# Any other type is "garbage" and must be rejected.
|
|
70
|
+
JSONABLE = [NilClass, TrueClass, FalseClass, Integer, Float,
|
|
71
|
+
String, Symbol].freeze
|
|
72
|
+
private_constant :JSONABLE
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def new_store
|
|
77
|
+
Hash.new { |h, scope| h[scope] = {} }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Enforces the contract's type model at the boundary:
|
|
81
|
+
# Symbol/symbol-key become String; a type outside the JSON model ->
|
|
82
|
+
# StoreError on WRITE (fail-fast; never writes garbage).
|
|
83
|
+
#
|
|
84
|
+
# Does not use `JSON.generate(strict: true)`: under json 2.7.1 (the pinned
|
|
85
|
+
# version) `strict` rejects Symbol, which would violate the Symbol coercion.
|
|
86
|
+
# The explicit validation is independent of the json version and gives the
|
|
87
|
+
# SAME semantics as SQLite.
|
|
88
|
+
# The transaction block's exception (from the caller) propagates without
|
|
89
|
+
# wrapping; only a backend error becomes StoreError.
|
|
90
|
+
def serialize(value)
|
|
91
|
+
ensure_jsonable!(value)
|
|
92
|
+
JSON.generate(value)
|
|
93
|
+
rescue JSON::GeneratorError => e
|
|
94
|
+
raise Insika::StoreError, "value not serializable: #{e.message}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def ensure_jsonable!(value)
|
|
98
|
+
case value
|
|
99
|
+
when *JSONABLE then nil
|
|
100
|
+
when Array then value.each { |v| ensure_jsonable!(v) }
|
|
101
|
+
when Hash then value.each { |k, v| ensure_jsonable!(k); ensure_jsonable!(v) }
|
|
102
|
+
else
|
|
103
|
+
raise Insika::StoreError,
|
|
104
|
+
"value not serializable: #{value.class} not allowed in JSON"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Deep dup: the values are already JSON strings (immutable in practice),
|
|
109
|
+
# so a per-scope dup is enough — there is no nested mutable structure.
|
|
110
|
+
def deep_snapshot
|
|
111
|
+
@data.each_with_object({}) { |(scope, kv), acc| acc[scope] = kv.dup }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Recreates the Hash with the default proc (otherwise @data[scope] on a
|
|
115
|
+
# new scope after rollback would raise) and restores only the snapshot's
|
|
116
|
+
# scopes — scopes created inside the transaction disappear.
|
|
117
|
+
def restore_snapshot
|
|
118
|
+
@data = new_store
|
|
119
|
+
@snapshot.each { |scope, kv| @data[scope] = kv }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|