insika 0.1.0 → 0.3.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 +199 -5
- data/README.md +8 -2
- data/bin/insika +231 -13
- data/docs/AGENTS.md +505 -6
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +100 -10
- data/docs/CONTEXT.md +147 -19
- data/docs/DEPLOY.md +34 -11
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +20 -1
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +17 -10
- data/docs/OBSERVABILITY.md +65 -2
- data/docs/REFINEMENT.md +9 -9
- data/docs/RELEASING.md +34 -7
- data/docs/RUNNING-LOCAL.md +4 -4
- data/docs/SECURITY.md +85 -11
- data/docs/SKILLS.md +189 -3
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +2 -2
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +228 -26
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +166 -0
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +132 -24
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +80 -6
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +9 -9
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +145 -13
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +6 -4
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -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 +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- 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/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/resolve_refinement.rb +1 -1
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/run_refinement.rb +5 -5
- data/lib/insika/commands/send_message.rb +112 -15
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +23 -5
- data/lib/insika/context/fragment.rb +31 -3
- data/lib/insika/context/priority.rb +6 -2
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +7 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +128 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +962 -7
- data/lib/insika/dsl/runtime.rb +20 -11
- data/lib/insika/dsl/server_boot.rb +74 -4
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +152 -15
- data/lib/insika/edge_limiter.rb +167 -8
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +22 -12
- data/lib/insika/errors.rb +72 -5
- data/lib/insika/evals/assertions.rb +15 -14
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +21 -9
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +1092 -160
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/frontmatter.rb +1 -1
- 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 +3 -3
- 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/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +8 -3
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +8 -3
- data/lib/insika/outbox_store.rb +44 -6
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- 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/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +34 -7
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +6 -3
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +10 -10
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +211 -0
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +46 -6
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +34 -115
- data/lib/insika/safety/factory.rb +18 -5
- 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 +20 -5
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +10 -6
- data/lib/insika/safety/output_validator.rb +13 -7
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +366 -54
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +31 -7
- data/lib/insika/server/responses.rb +58 -9
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +11 -7
- data/lib/insika/session_store.rb +66 -3
- data/lib/insika/settings_store.rb +15 -5
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +131 -20
- data/lib/insika/skill_store.rb +70 -22
- 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 +1 -1
- data/lib/insika/store.rb +11 -2
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1058 -75
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +27 -26
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +274 -22
- data/lib/insika/studio/nav_icons.rb +7 -2
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +629 -86
- data/lib/insika/studio/views/agents.erb +11 -7
- data/lib/insika/studio/views/approvals.erb +4 -1
- 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 +4 -1
- 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 +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +133 -3
- data/lib/insika/studio/views/settings.erb +9 -12
- data/lib/insika/studio/views/skills.erb +66 -12
- 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 +0 -1
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +22 -2
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +54 -33
- data/lib/insika/tick.rb +146 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +25 -15
- data/lib/insika/tool_envelope.rb +70 -1
- data/lib/insika/tool_manifest.rb +11 -7
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +14 -5
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +54 -13
- data/lib/insika/turn_timing.rb +24 -4
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +372 -34
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +122 -16
- metadata +95 -2
- data/lib/insika/server/admin_auth.rb +0 -29
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# C2 — one durable record per mirrored exchange (shadow mode),
|
|
8
|
+
# written by TWO INDEPENDENT HALVES: ours at the turn's terminal, the
|
|
9
|
+
# incumbent's at the mirror. Both land on the same key — a digest of
|
|
10
|
+
# (channel, external_id, event_id), deterministic and order-free — so the two
|
|
11
|
+
# writers converge without an index and without ordering assumptions.
|
|
12
|
+
#
|
|
13
|
+
# It stores and it counts; it does not judge, does not fold a verdict, does
|
|
14
|
+
# not know what the criterion says.
|
|
15
|
+
#
|
|
16
|
+
# Status transitions (never backwards):
|
|
17
|
+
#
|
|
18
|
+
# ┌─ record_incumbent ─┐
|
|
19
|
+
# (nothing) ────┤ ├─▶ open ─▶ complete ─▶ judged
|
|
20
|
+
# └─ record_ours ─────┘ └─▶ silent (never judged)
|
|
21
|
+
# └─▶ incomplete (expire)
|
|
22
|
+
class ShadowPairStore
|
|
23
|
+
SCOPE = "shadow_pairs"
|
|
24
|
+
KEY_PREFIX = "pair:"
|
|
25
|
+
|
|
26
|
+
STATUSES = %i[open complete silent judged incomplete].freeze
|
|
27
|
+
|
|
28
|
+
Pair = Data.define(
|
|
29
|
+
:id, :channel, :agent, :session_id, :task_id, :event_id,
|
|
30
|
+
:inbound, :incumbent_reply, :insika_reply,
|
|
31
|
+
:status, :verdict, :criterion_sha, :created_at, :updated_at
|
|
32
|
+
) do
|
|
33
|
+
def complete? = %i[complete silent].include?(status)
|
|
34
|
+
def judged? = status == :judged
|
|
35
|
+
def outcome = verdict && verdict["outcome"]
|
|
36
|
+
def human_assisted? = verdict && verdict["vs"] == "human-assisted"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(store:)
|
|
40
|
+
@store = store
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The correlation key BOTH writers compute independently. SHA-256 hex of
|
|
44
|
+
# "<channel>\0<external_id>\0<event_id>", truncated to 32 — deterministic,
|
|
45
|
+
# order-free, and it keeps a phone number out of the store's key space.
|
|
46
|
+
def self.key_for(channel:, external_id:, event_id:)
|
|
47
|
+
Digest::SHA256.hexdigest("#{channel}\0#{external_id}\0#{event_id}")[0, 32]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Our half. Upsert: creates the record or fills our fields on the incumbent's.
|
|
51
|
+
# `reply` may be "" — a turn that published nothing (halt_when, an out-of-band
|
|
52
|
+
# tool) is recorded as :silent rather than left invisible. -> Pair
|
|
53
|
+
def record_ours(id:, channel:, agent:, session_id:, task_id:, event_id:,
|
|
54
|
+
inbound:, reply:, criterion_sha:)
|
|
55
|
+
upsert(id) do |record, created|
|
|
56
|
+
record["channel"] = channel.to_s
|
|
57
|
+
record["event_id"] = event_id.to_s
|
|
58
|
+
record["agent"] = agent
|
|
59
|
+
record["session_id"] = session_id&.to_s
|
|
60
|
+
record["task_id"] = task_id&.to_s
|
|
61
|
+
record["inbound"] = inbound.to_s
|
|
62
|
+
record["insika_reply"] = reply.to_s
|
|
63
|
+
record["criterion_sha"] = criterion_sha
|
|
64
|
+
created
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The incumbent's half (the mirror contract). Same upsert shape; the fields
|
|
69
|
+
# this half owns are the reply and, on first write, the timestamp the mirror
|
|
70
|
+
# reports. Never overwrites our half's fields. First-write-wins is enforced
|
|
71
|
+
# HERE, inside the transaction: the customer received ONE reply, and two
|
|
72
|
+
# concurrent mirror retries must not let the second rewrite the evidence.
|
|
73
|
+
# -> Pair
|
|
74
|
+
def record_incumbent(id:, channel:, event_id:, external_id:, reply:, at: nil)
|
|
75
|
+
upsert(id, at: at) do |record, created|
|
|
76
|
+
record["channel"] = channel.to_s
|
|
77
|
+
record["event_id"] = event_id.to_s
|
|
78
|
+
record["incumbent_reply"] = reply.to_s if record["incumbent_reply"].nil?
|
|
79
|
+
created
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# -> Pair | nil
|
|
84
|
+
def find(id)
|
|
85
|
+
record = @store.get(SCOPE, key_for(id))
|
|
86
|
+
record && to_pair(record)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Lazy scan over a SNAPSHOT of the keys (deleting under a live enumeration
|
|
90
|
+
# would skip records — the same rule OutboxStore applies).
|
|
91
|
+
def each(&block)
|
|
92
|
+
return enum_for(:each) unless block_given?
|
|
93
|
+
|
|
94
|
+
@store.list(SCOPE, KEY_PREFIX).each do |key|
|
|
95
|
+
record = @store.get(SCOPE, key)
|
|
96
|
+
yield to_pair(record) if record
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# -> [Pair] created at or after `time`.
|
|
101
|
+
def since(time)
|
|
102
|
+
cutoff = time.utc.iso8601
|
|
103
|
+
each.select { |p| p.created_at.to_s >= cutoff }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# -> [Pair] status :complete, oldest first — the judging queue. `silent`
|
|
107
|
+
# pairs are NEVER here: finding that pairwise is
|
|
108
|
+
# systematically unfair to a tool that delivers out of band is not
|
|
109
|
+
# something to average away.
|
|
110
|
+
def unjudged(limit: nil, agent: nil)
|
|
111
|
+
pairs = each.select { |p| p.status == :complete }
|
|
112
|
+
.sort_by { |p| p.created_at.to_s }
|
|
113
|
+
pairs = pairs.select { |p| p.agent.to_s == agent.to_s } if agent
|
|
114
|
+
limit ? pairs.first(limit.to_i) : pairs
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# -> { open:, complete:, silent:, judged:, incomplete: }
|
|
118
|
+
def counts(since: nil)
|
|
119
|
+
pairs = since ? self.since(since) : each.to_a
|
|
120
|
+
STATUSES.to_h { |s| [s, pairs.count { |p| p.status == s }] }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The panel's Verdict as data. status -> :judged. -> Pair
|
|
124
|
+
def record_verdict(id, verdict:)
|
|
125
|
+
@store.transaction do
|
|
126
|
+
key = key_for(id)
|
|
127
|
+
record = @store.get(SCOPE, key)
|
|
128
|
+
raise Insika::NotFoundError, "shadow pair not found: #{id}" unless record
|
|
129
|
+
|
|
130
|
+
record["verdict"] = Coercion.deep_stringify(verdict)
|
|
131
|
+
record["status"] = "judged"
|
|
132
|
+
record["updated_at"] = timestamp
|
|
133
|
+
@store.set(SCOPE, key, record)
|
|
134
|
+
to_pair(record)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# An `open` pair older than the cutoff will never complete. -> count moved.
|
|
139
|
+
# `complete`/`silent`/`judged` are never touched. Update-style only: a pair
|
|
140
|
+
# deleted between the scan and the write (retention, LGPD purge) is left
|
|
141
|
+
# deleted — an upsert here would resurrect it as a ghost :incomplete record
|
|
142
|
+
# carrying none of its fields.
|
|
143
|
+
def expire(older_than:)
|
|
144
|
+
cutoff = older_than.utc.iso8601
|
|
145
|
+
moved = 0
|
|
146
|
+
each.select { |p| p.status == :open && p.created_at.to_s < cutoff }.each do |pair|
|
|
147
|
+
@store.transaction do
|
|
148
|
+
key = key_for(pair.id)
|
|
149
|
+
record = @store.get(SCOPE, key)
|
|
150
|
+
next unless record && record["status"] == "open"
|
|
151
|
+
|
|
152
|
+
record["status"] = "incomplete"
|
|
153
|
+
record["updated_at"] = timestamp
|
|
154
|
+
@store.set(SCOPE, key, record)
|
|
155
|
+
moved += 1
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
moved
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# -> Integer. Keys only, no record materialization — a count of pairs must
|
|
162
|
+
# not pay for customer text (doctor's shadow-off check, the Studio).
|
|
163
|
+
def size = @store.list(SCOPE, KEY_PREFIX).length
|
|
164
|
+
|
|
165
|
+
# LGPD / retention (C9): drops every pair of these sessions, whatever its
|
|
166
|
+
# status — the pair holds the customer's own words. -> count removed.
|
|
167
|
+
def purge_sessions(session_ids)
|
|
168
|
+
wanted = Array(session_ids).map(&:to_s)
|
|
169
|
+
return 0 if wanted.empty?
|
|
170
|
+
|
|
171
|
+
doomed = each.select { |p| wanted.include?(p.session_id.to_s) }
|
|
172
|
+
doomed.each { |p| @store.delete(SCOPE, key_for(p.id)) }
|
|
173
|
+
doomed.size
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Retention: pairs created before the cutoff, TERMINAL statuses only
|
|
177
|
+
# (`judged`/`incomplete`) — an `open`/`complete` record older than the
|
|
178
|
+
# window is still someone's unjudged evidence. -> count removed.
|
|
179
|
+
def delete_older_than(time)
|
|
180
|
+
cutoff = time.utc.iso8601
|
|
181
|
+
doomed = each.select do |p|
|
|
182
|
+
%i[judged incomplete].include?(p.status) && p.created_at.to_s < cutoff
|
|
183
|
+
end
|
|
184
|
+
doomed.each { |p| @store.delete(SCOPE, key_for(p.id)) }
|
|
185
|
+
doomed.size
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
# The shared upsert: read -> merge only the fields THIS half owns (never
|
|
191
|
+
# overwrite the other's with nil) -> recompute status -> write. Runs inside
|
|
192
|
+
# Store#transaction so two halves landing in the same instant serialize on
|
|
193
|
+
# the backend's lock — the same claim mechanic OutboxStore#claim uses.
|
|
194
|
+
def upsert(id, at: nil, &fill)
|
|
195
|
+
@store.transaction do
|
|
196
|
+
key = key_for(id)
|
|
197
|
+
record = @store.get(SCOPE, key)
|
|
198
|
+
created = record.nil?
|
|
199
|
+
unless record
|
|
200
|
+
record = {
|
|
201
|
+
"id" => id.to_s, "channel" => nil, "agent" => nil, "session_id" => nil,
|
|
202
|
+
"task_id" => nil, "event_id" => nil, "inbound" => nil,
|
|
203
|
+
"incumbent_reply" => nil, "insika_reply" => nil, "status" => "open",
|
|
204
|
+
"verdict" => nil, "criterion_sha" => nil,
|
|
205
|
+
"created_at" => arrival_time(at), "updated_at" => timestamp
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
fill.call(record, created)
|
|
209
|
+
record["status"] = status_for(record)
|
|
210
|
+
record["updated_at"] = timestamp
|
|
211
|
+
@store.set(SCOPE, key, record)
|
|
212
|
+
to_pair(record)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Status recomputation, in one place (never backwards — a judged pair stays
|
|
217
|
+
# judged, an expired one stays incomplete):
|
|
218
|
+
def status_for(record)
|
|
219
|
+
return record["status"] if %w[judged incomplete].include?(record["status"])
|
|
220
|
+
return "open" if record["insika_reply"].nil? || record["incumbent_reply"].nil?
|
|
221
|
+
return "silent" if record["insika_reply"].to_s.strip.empty?
|
|
222
|
+
|
|
223
|
+
"complete"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def key_for(id) = "#{KEY_PREFIX}#{id}"
|
|
227
|
+
|
|
228
|
+
# The mirror's reported time on first write; nil = now. A String rides
|
|
229
|
+
# through as-is (it is the wire format); a Time is normalized to ISO8601.
|
|
230
|
+
# A String is ALSO normalized to UTC ISO8601: the mirrors report local
|
|
231
|
+
# offsets (+09:00, -03:00) and every comparison against created_at
|
|
232
|
+
# (since/expire/retention/unjudged ordering) is lexicographic — two offsets
|
|
233
|
+
# would make those comparisons lie. Unparseable input keeps the old
|
|
234
|
+
# ride-through behaviour rather than refusing the pair.
|
|
235
|
+
def arrival_time(at)
|
|
236
|
+
return timestamp if at.nil?
|
|
237
|
+
return at.utc.iso8601 unless at.is_a?(String)
|
|
238
|
+
|
|
239
|
+
Time.iso8601(at).utc.iso8601
|
|
240
|
+
rescue ArgumentError
|
|
241
|
+
at
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def to_pair(record)
|
|
245
|
+
Pair.new(
|
|
246
|
+
id: record["id"], channel: record["channel"], agent: record["agent"],
|
|
247
|
+
session_id: record["session_id"], task_id: record["task_id"],
|
|
248
|
+
event_id: record["event_id"], inbound: record["inbound"],
|
|
249
|
+
incumbent_reply: record["incumbent_reply"], insika_reply: record["insika_reply"],
|
|
250
|
+
status: record["status"].to_sym, verdict: record["verdict"],
|
|
251
|
+
criterion_sha: record["criterion_sha"],
|
|
252
|
+
created_at: record["created_at"], updated_at: record["updated_at"]
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def timestamp = Time.now.utc.iso8601
|
|
257
|
+
end
|
|
258
|
+
end
|
data/lib/insika/shutdown.rb
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Insika
|
|
4
|
-
#
|
|
5
|
-
#
|
|
4
|
+
# shutdown is a drain, not a kill (docs/DEPLOY.md, process model
|
|
5
|
+
# The serving arms install this around the Executor. On the first
|
|
6
6
|
# SIGTERM/SIGINT the process stops accepting new turns (`Executor#begin_drain!`
|
|
7
7
|
# — a turn arriving mid-drain is left `:queued` for the next boot's recovery)
|
|
8
8
|
# and waits up to `timeout` seconds for the in-flight ones; only then does the
|
|
9
9
|
# ordinary stop proceed. A second signal skips the wait — the operator insisting
|
|
10
10
|
# means now. Whatever the deadline abandons dies `:running` with the process and
|
|
11
11
|
# the next boot generation's task sweep replays it from its checkpoint
|
|
12
|
-
# (side-effect skip on resume is what makes that replay safe
|
|
12
|
+
# (side-effect skip on resume is what makes that replay safe).
|
|
13
13
|
#
|
|
14
14
|
# Mechanics, because trap context is narrow: the handler writes ONE byte into a
|
|
15
15
|
# self-pipe and returns. A plain watcher THREAD — not a fiber: at install time
|
|
@@ -27,7 +27,7 @@ module Insika
|
|
|
27
27
|
# traps, parks the watcher. The CALLING thread is captured as the stop target
|
|
28
28
|
# — install from the thread that runs the server.
|
|
29
29
|
#
|
|
30
|
-
# `executors:`
|
|
30
|
+
# `executors:` drains N graphs on one signal. Signals are a
|
|
31
31
|
# PROCESS concern, and `Signal.trap` keeps only the last handler — so a second
|
|
32
32
|
# `install` per graph would silently leave the earlier graphs dying mid-turn.
|
|
33
33
|
# The host installs ONCE, naming every graph it embedded. `executor:` is the
|
data/lib/insika/skill_catalog.rb
CHANGED
|
@@ -11,7 +11,16 @@ module Insika
|
|
|
11
11
|
# Consumed by the Executor (skill_catalog:) and by stage 3
|
|
12
12
|
# (effective/format_for_prompt).
|
|
13
13
|
class SkillCatalog
|
|
14
|
-
|
|
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)
|
|
15
24
|
|
|
16
25
|
# roots ordered by PRECEDENCE (highest first): workspace, managed,
|
|
17
26
|
# bundled. Same name in more than one root: the first wins.
|
|
@@ -22,37 +31,97 @@ module Insika
|
|
|
22
31
|
def initialize(roots, store: nil)
|
|
23
32
|
@roots = Array(roots)
|
|
24
33
|
@store = store
|
|
25
|
-
@skills = load_all
|
|
34
|
+
@skills, @agent_skills = load_all
|
|
26
35
|
end
|
|
27
36
|
|
|
28
|
-
|
|
29
|
-
|
|
37
|
+
# The SkillStore the catalog overlays — the composition root hands it to
|
|
38
|
+
# the harvest (the dedup reads the AUTHORED skills the catalog serves).
|
|
39
|
+
attr_reader :store
|
|
40
|
+
|
|
41
|
+
# `agent` (an agent id) resolves the AGENT SCOPE first, then the shared one — the
|
|
42
|
+
# same precedence chain the catalog already runs for store-over-disk and
|
|
43
|
+
# workspace-over-managed-over-bundled, with one more dimension.
|
|
44
|
+
#
|
|
45
|
+
# Three cases fall out of that one rule: SHARED (only the shared record exists),
|
|
46
|
+
# OVERRIDE (both exist, the agent's wins) and AGENT-PRIVATE (only the agent record
|
|
47
|
+
# exists — invisible elsewhere, and its name may collide freely).
|
|
48
|
+
#
|
|
49
|
+
# Without `agent` the shared scope is all there is, which is what every caller
|
|
50
|
+
# that has no agent in hand (the Studio's shared editor, a bare catalog) means.
|
|
51
|
+
def all(agent: nil)
|
|
52
|
+
shared = @skills
|
|
53
|
+
overrides = agent_scope(agent)
|
|
54
|
+
return shared.values if overrides.empty?
|
|
55
|
+
|
|
56
|
+
shared.merge(overrides).values
|
|
30
57
|
end
|
|
31
58
|
|
|
32
|
-
def find(name)
|
|
33
|
-
@skills[name.to_s]
|
|
59
|
+
def find(name, agent: nil)
|
|
60
|
+
agent_scope(agent)[name.to_s] || @skills[name.to_s]
|
|
34
61
|
end
|
|
35
62
|
|
|
36
63
|
# Reloads from disk + Store and SWAPS the index atomically: an
|
|
37
64
|
# authored/edited skill takes effect without a restart. A turn in progress
|
|
38
65
|
# captured @skills at dispatch, so it does not see the swap mid-flight.
|
|
39
66
|
def reload
|
|
40
|
-
@skills = load_all
|
|
67
|
+
@skills, @agent_skills = load_all
|
|
41
68
|
self
|
|
42
69
|
end
|
|
43
70
|
|
|
44
|
-
# Per-agent allowlist: nil -> all | [] -> none | [names] -> subset.
|
|
45
|
-
|
|
46
|
-
|
|
71
|
+
# Per-agent allowlist: nil -> all | [] -> none | [names] -> subset. `agent`
|
|
72
|
+
# selects WHICH body each allowed name resolves to (see #find); the allowlist is
|
|
73
|
+
# by NAME either way, so specializing a skill never touches the allowlist.
|
|
74
|
+
def effective(skills_policy, agent: nil)
|
|
75
|
+
Allowlist.filter(all(agent: agent), skills_policy) { |s| s.name }
|
|
47
76
|
end
|
|
48
77
|
|
|
78
|
+
# THE single definition of "always in the prompt", consulted by all three
|
|
79
|
+
# surfaces that must agree: the body provider (injects these), the level-1
|
|
80
|
+
# catalog (hides them) and load_skill (refuses them). Split the rule across three
|
|
81
|
+
# files and they drift — which is the failure this whole feature came from.
|
|
82
|
+
#
|
|
83
|
+
# `profile.skills_eager` — a PER-AGENT decision, so a shared skill stays shared:
|
|
84
|
+
# nil | false -> none (progressive disclosure; the default)
|
|
85
|
+
# true -> every allowed skill (blanket; only for a corpus that fits the budget)
|
|
86
|
+
# [names] -> exactly these
|
|
87
|
+
#
|
|
88
|
+
# Deliberately NOT `Allowlist.filter`: there nil means ALL, which is the safe
|
|
89
|
+
# default for `skills`/`tools_allow` where nil is "no policy". Here nil must mean
|
|
90
|
+
# NONE — an unconfigured agent waking up with every skill body on every turn is
|
|
91
|
+
# the opposite of a safe default. A name that is not in the agent's `skills`
|
|
92
|
+
# allowlist is a silent no-op here (the intersection with `effective`); `doctor`
|
|
93
|
+
# flags it, because the operator who wrote the name meant it.
|
|
94
|
+
def eager_for(profile)
|
|
95
|
+
allowed = effective(profile.skills, agent: profile.id)
|
|
96
|
+
spec = profile.skills_eager
|
|
97
|
+
return allowed if blanket?(spec)
|
|
98
|
+
return [] if spec.nil? || spec == false
|
|
99
|
+
|
|
100
|
+
names = Array(spec).map { |n| n.to_s.strip }
|
|
101
|
+
allowed.select { |s| names.include?(s.name) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The complement: what the model still has to ASK for — and therefore what the
|
|
105
|
+
# level-1 list advertises and load_skill will serve.
|
|
106
|
+
def lazy_for(profile) = effective(profile.skills, agent: profile.id) - eager_for(profile)
|
|
107
|
+
|
|
49
108
|
# Level 1: compact list injected into the system prompt. Metadata only.
|
|
50
109
|
# Receives the set already filtered by the agent.
|
|
110
|
+
#
|
|
111
|
+
# `when=` carries the skill's `triggers:` — THE ROUTING TABLE, GENERATED. What
|
|
112
|
+
# actually made activation reliable on the pilot was a hand-written companion file
|
|
113
|
+
# listing each skill with its trigger phrases, and nothing checked it against the
|
|
114
|
+
# catalog: a skill created at 11:28 was invisible to a table written the day
|
|
115
|
+
# before, and the model obeyed the table. Rendering the same information from the
|
|
116
|
+
# catalog means it cannot disagree with the allowlist — a newly allowed skill
|
|
117
|
+
# appears the moment it is allowed. Detecting that drift would have been strictly
|
|
118
|
+
# worse than removing its source.
|
|
51
119
|
def format_for_prompt(skills = all)
|
|
52
120
|
return "" if skills.empty?
|
|
53
121
|
|
|
54
122
|
entries = skills.map do |s|
|
|
55
|
-
%(
|
|
123
|
+
when_attr = Array(s.triggers).empty? ? "" : %( when="#{Array(s.triggers).join('; ')}")
|
|
124
|
+
%( <skill name="#{s.name}"#{when_attr}>#{s.description}</skill>)
|
|
56
125
|
end.join("\n")
|
|
57
126
|
|
|
58
127
|
<<~PROMPT.strip
|
|
@@ -60,13 +129,24 @@ module Insika
|
|
|
60
129
|
#{entries}
|
|
61
130
|
</available_skills>
|
|
62
131
|
|
|
63
|
-
Before
|
|
64
|
-
|
|
132
|
+
Before ANY reply or tool call: scan the skills above. If one matches
|
|
133
|
+
or is even partially relevant to the task, you MUST call
|
|
134
|
+
`load_skill("name")` FIRST and follow what it returns. Err on the
|
|
135
|
+
side of loading. Only skip when genuinely none apply.
|
|
65
136
|
PROMPT
|
|
66
137
|
end
|
|
67
138
|
|
|
68
139
|
private
|
|
69
140
|
|
|
141
|
+
# The blanket switch, tolerant of the strings a form / JSON round-trip produces
|
|
142
|
+
# ("1" from a checkbox, "true" from a pack) — same reading as
|
|
143
|
+
# AgentProfile#stream_public?. Anything else (a list, nil, false) is not blanket.
|
|
144
|
+
def blanket?(spec) = Coercion.truthy?(spec)
|
|
145
|
+
|
|
146
|
+
# An agent's override index; {} for a nil agent or one that specialized nothing.
|
|
147
|
+
def agent_scope(agent) = agent.nil? ? {} : (@agent_skills[agent.to_s] || {})
|
|
148
|
+
|
|
149
|
+
# -> [shared index, { agent_id => index }].
|
|
70
150
|
def load_all
|
|
71
151
|
found = {}
|
|
72
152
|
@roots.each do |root|
|
|
@@ -78,7 +158,7 @@ module Insika
|
|
|
78
158
|
end
|
|
79
159
|
end
|
|
80
160
|
overlay_store(found)
|
|
81
|
-
found
|
|
161
|
+
[found, load_agent_scopes]
|
|
82
162
|
end
|
|
83
163
|
|
|
84
164
|
# Store skills overlay the on-disk ones (authored > seed). Sentinel path
|
|
@@ -87,27 +167,58 @@ module Insika
|
|
|
87
167
|
return unless @store
|
|
88
168
|
|
|
89
169
|
@store.all.each do |name, content|
|
|
90
|
-
skill = parse_content(content.to_s, path: "store:#{name}")
|
|
91
|
-
found[
|
|
170
|
+
skill = parse_content(content.to_s, path: "store:#{name}", key: name)
|
|
171
|
+
found[name.to_s] = skill if skill # Store wins
|
|
92
172
|
end
|
|
93
173
|
end
|
|
94
174
|
|
|
95
|
-
|
|
175
|
+
# Per-agent overrides / private skills, one index per agent. A store that predates
|
|
176
|
+
# the agent dimension answers nothing here, so this is {} and every lookup falls
|
|
177
|
+
# straight through to the shared scope.
|
|
178
|
+
def load_agent_scopes
|
|
179
|
+
return {} unless @store.respond_to?(:agents)
|
|
180
|
+
|
|
181
|
+
@store.agents.each_with_object({}) do |agent, acc|
|
|
182
|
+
index = {}
|
|
183
|
+
@store.all(agent: agent).each do |name, content|
|
|
184
|
+
skill = parse_content(content.to_s, path: "store:#{agent}/#{name}", key: name)
|
|
185
|
+
index[name.to_s] = skill if skill
|
|
186
|
+
end
|
|
187
|
+
acc[agent.to_s] = index unless index.empty?
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# `key` = THE STORE POSITION, and it wins over the frontmatter `name:`. An override
|
|
192
|
+
# authored for one agent still says `name: escalation-to-human` inside — that is
|
|
193
|
+
# deliberate, it is the same skill specialized — and indexing by the parsed name
|
|
194
|
+
# would clobber the shared record globally, which is the exact bug the agent scope
|
|
195
|
+
# exists to fix. It also makes a pack whose directory name and frontmatter name
|
|
196
|
+
# disagree resolvable: the allowlist is written from the directory.
|
|
197
|
+
def parse_content(raw, path:, key: nil)
|
|
96
198
|
match = raw.match(/\A---\s*\n(.*?)\n---\s*\n(.*)\z/m)
|
|
97
199
|
return nil unless match
|
|
98
200
|
|
|
99
201
|
# Tolerant frontmatter: real packs have `: ` in the description prose, which
|
|
100
202
|
# strict YAML rejected (the pack would not load).
|
|
101
203
|
meta = Insika::Frontmatter.parse(match[1])
|
|
102
|
-
name = meta["name"]
|
|
103
|
-
return nil unless name
|
|
204
|
+
name = key || meta["name"]
|
|
205
|
+
return nil unless name && Coercion.present?(meta["name"])
|
|
104
206
|
|
|
105
207
|
Skill.new(
|
|
106
208
|
name: name.to_s,
|
|
107
209
|
description: meta["description"].to_s,
|
|
108
210
|
path: path,
|
|
109
|
-
body: match[2].strip
|
|
211
|
+
body: match[2].strip,
|
|
212
|
+
triggers: parse_list(meta["triggers"]),
|
|
213
|
+
companions: parse_list(meta["companions"])
|
|
110
214
|
)
|
|
111
215
|
end
|
|
216
|
+
|
|
217
|
+
# `triggers:` / `companions:` frontmatter. YAML list, or comma-separated string
|
|
218
|
+
# under the lenient parse (which yields the whole value as one String).
|
|
219
|
+
def parse_list(raw)
|
|
220
|
+
list = raw.is_a?(String) ? raw.split(",") : Array(raw)
|
|
221
|
+
list.map { |t| t.to_s.strip }.reject(&:empty?)
|
|
222
|
+
end
|
|
112
223
|
end
|
|
113
224
|
end
|
data/lib/insika/skill_store.rb
CHANGED
|
@@ -3,20 +3,37 @@
|
|
|
3
3
|
require "time"
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
|
-
# AUTHORED
|
|
6
|
+
# AUTHORED skills, in two scopes.
|
|
7
7
|
# Holds the complete SKILL.md (frontmatter + body) in the durable Store. The
|
|
8
8
|
# SkillCatalog overlays these skills on top of the on-disk ones (seed), with the Store
|
|
9
9
|
# winning — so editing/creating a skill in the Studio takes effect without a restart (via reload).
|
|
10
10
|
#
|
|
11
|
-
#
|
|
11
|
+
# SHARED scope (`agent:` omitted) — one record per skill in the ConfigStore
|
|
12
|
+
# (scope "skills"), keyed by the skill name:
|
|
12
13
|
# { "content" => "<entire SKILL.md>",
|
|
13
14
|
# "updated_at" => iso8601,
|
|
14
15
|
# "history" => [ { "content" =>, "at" => }, ... ] }
|
|
15
16
|
#
|
|
16
|
-
#
|
|
17
|
-
# the AgentFileStore
|
|
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.
|
|
18
34
|
class SkillStore
|
|
19
35
|
SCOPE = "skills"
|
|
36
|
+
AGENT_SCOPE = "agent_skills"
|
|
20
37
|
HISTORY_MAX = 20
|
|
21
38
|
|
|
22
39
|
def initialize(config_store:)
|
|
@@ -24,48 +41,79 @@ module Insika
|
|
|
24
41
|
end
|
|
25
42
|
|
|
26
43
|
# -> String | nil (complete SKILL.md).
|
|
27
|
-
def get(name)
|
|
28
|
-
record(name)&.fetch("content", nil)
|
|
44
|
+
def get(name, agent: nil)
|
|
45
|
+
record(name, agent)&.fetch("content", nil)
|
|
29
46
|
end
|
|
30
47
|
|
|
31
|
-
# -> [String] names, lexicographic order.
|
|
32
|
-
def names
|
|
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
|
|
33
52
|
|
|
34
|
-
# -> { name => content } of
|
|
35
|
-
def all
|
|
36
|
-
|
|
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) }
|
|
37
56
|
end
|
|
38
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
|
+
|
|
39
62
|
# Writes (upsert). create_only refuses to overwrite. -> Hash (the stored record).
|
|
40
|
-
def write(name, content, create_only: false)
|
|
63
|
+
def write(name, content, agent: nil, create_only: false)
|
|
41
64
|
key = name.to_s
|
|
42
|
-
current =
|
|
43
|
-
raise Insika::ValidationError, "skill '#{key}' already exists" if create_only && current
|
|
65
|
+
current = record(key, agent)
|
|
66
|
+
raise Insika::ValidationError, "skill '#{key}' already exists#{" for agent '#{agent}'" if agent}" if create_only && current
|
|
44
67
|
|
|
45
68
|
rec = build_record(content.to_s, current)
|
|
46
|
-
|
|
69
|
+
put(key, rec, agent)
|
|
47
70
|
rec
|
|
48
71
|
end
|
|
49
72
|
|
|
50
73
|
# -> bool (did it exist?).
|
|
51
|
-
def delete(name
|
|
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
|
|
52
85
|
|
|
53
86
|
# -> [ { "content" =>, "at" => } ] most recent first.
|
|
54
|
-
def versions(name) = record(name)&.fetch("history", []) || []
|
|
87
|
+
def versions(name, agent: nil) = record(name, agent)&.fetch("history", []) || []
|
|
55
88
|
|
|
56
89
|
# Restores version `index` as the current content (a new write). -> Hash.
|
|
57
|
-
def restore(name, index)
|
|
58
|
-
hist = versions(name)
|
|
90
|
+
def restore(name, index, agent: nil)
|
|
91
|
+
hist = versions(name, agent: agent)
|
|
59
92
|
i = Integer(index)
|
|
60
|
-
raise Insika::NotFoundError, "skill '#{name}' not found" unless record(name)
|
|
93
|
+
raise Insika::NotFoundError, "skill '#{name}' not found" unless record(name.to_s, agent)
|
|
61
94
|
raise Insika::ValidationError, "version #{index} does not exist" if i.negative? || i >= hist.length
|
|
62
95
|
|
|
63
|
-
write(name, hist[i]["content"])
|
|
96
|
+
write(name, hist[i]["content"], agent: agent)
|
|
64
97
|
end
|
|
65
98
|
|
|
66
99
|
private
|
|
67
100
|
|
|
68
|
-
def record(name
|
|
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"] || {}
|
|
69
117
|
|
|
70
118
|
def build_record(content, current)
|
|
71
119
|
history = current ? current.fetch("history", []) : []
|