insika 0.0.1 → 0.1.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 +295 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +351 -0
- data/docs/AGENTS.md +494 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +100 -0
- data/docs/DEPLOY.md +334 -0
- data/docs/EMBEDDING.md +194 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +231 -0
- data/docs/OBSERVABILITY.md +365 -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 +362 -0
- data/docs/SKILLS.md +98 -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 +188 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +150 -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/chat_builder.rb +254 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/coercion.rb +50 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +41 -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_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/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/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 +71 -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 +50 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +85 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +19 -0
- data/lib/insika/context/priority.rb +29 -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 +108 -0
- data/lib/insika/context/providers/skill.rb +20 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +294 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +379 -0
- data/lib/insika/dsl/server_boot.rb +97 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +307 -0
- data/lib/insika/edge_limiter.rb +130 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +246 -0
- data/lib/insika/errors.rb +145 -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 +114 -0
- data/lib/insika/executor.rb +1680 -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/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 +114 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +103 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +121 -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 +137 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +127 -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/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 +87 -0
- data/lib/insika/safety/moderator.rb +86 -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/admin_auth.rb +29 -0
- data/lib/insika/server/app.rb +850 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +110 -0
- data/lib/insika/server/responses.rb +155 -0
- data/lib/insika/server/sse_body.rb +96 -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 +113 -0
- data/lib/insika/skill_store.rb +79 -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 +1571 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +69 -0
- data/lib/insika/studio/forms.rb +340 -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 +62 -0
- data/lib/insika/studio/views/settings.erb +173 -0
- data/lib/insika/studio/views/skills.erb +86 -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/token_estimator.rb +16 -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_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 +220 -0
- data/lib/insika/tools/load_skill.rb +41 -0
- data/lib/insika/tools/remember.rb +53 -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 +158 -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 +198 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +203 -4
- metadata +395 -8
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Domain store for sessions. Persists transcript + vars
|
|
8
|
+
# over an injected Insika::Store, with a fixed schema
|
|
9
|
+
# `session:<id>` in the "sessions" scope.
|
|
10
|
+
#
|
|
11
|
+
# The persisted transcript is the SOURCE OF TRUTH for reconstruction; live
|
|
12
|
+
# events are just delivery state. The message shape
|
|
13
|
+
# (`{"role"=>, "content"=>}`, role ∈ user|assistant|system|tool) is the
|
|
14
|
+
# same one `Runner#seed_history` already consumes — the Executor converts nothing.
|
|
15
|
+
#
|
|
16
|
+
# Normalizes symbol→string on WRITE (the backend only guarantees round-trip of
|
|
17
|
+
# JSON types); READ returns the data as it comes from the backend
|
|
18
|
+
# (string keys), never symmetrizing back to symbols.
|
|
19
|
+
class SessionStore
|
|
20
|
+
include Coercion
|
|
21
|
+
|
|
22
|
+
SCOPE = "sessions"
|
|
23
|
+
KEY_PREFIX = "session:"
|
|
24
|
+
|
|
25
|
+
Session = Data.define(:id, :messages, :vars, :memory_refs,
|
|
26
|
+
:created_at, :updated_at)
|
|
27
|
+
|
|
28
|
+
# store: any Insika::Store (Memory, SQLite, ...) — injected by the
|
|
29
|
+
# composition root (config/wiring.rb). SessionStore does not know the
|
|
30
|
+
# concrete backend.
|
|
31
|
+
def initialize(store:)
|
|
32
|
+
@store = store
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# -> Session; ArgumentError if id already exists (a duplicate session is a
|
|
36
|
+
# domain violation — it never overwrites silently).
|
|
37
|
+
def create(id: SecureRandom.uuid, vars: {})
|
|
38
|
+
key = key_for(id)
|
|
39
|
+
raise ArgumentError, "session already exists: #{id}" unless @store.get(SCOPE, key).nil?
|
|
40
|
+
|
|
41
|
+
now = timestamp
|
|
42
|
+
record = {
|
|
43
|
+
"id" => id.to_s,
|
|
44
|
+
"messages" => [],
|
|
45
|
+
"vars" => deep_stringify(vars),
|
|
46
|
+
"memory_refs" => [],
|
|
47
|
+
"created_at" => now,
|
|
48
|
+
"updated_at" => now
|
|
49
|
+
}
|
|
50
|
+
@store.set(SCOPE, key, record)
|
|
51
|
+
to_session(record)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# -> Session | nil
|
|
55
|
+
def find(id)
|
|
56
|
+
record = @store.get(SCOPE, key_for(id))
|
|
57
|
+
record && to_session(record)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# -> Session (transcript += messages). Read-modify-write on the task's own
|
|
61
|
+
# fiber, without a lock. Each message gets an "at" (ISO8601 UTC) if not
|
|
62
|
+
# provided. NotFoundError if the session does not exist.
|
|
63
|
+
#
|
|
64
|
+
# CONCURRENCY LIMITATION (§11 R2c): the RMW (read record -> += -> set) is
|
|
65
|
+
# atomic ONLY because the SessionActor serializes turns of the same session
|
|
66
|
+
# (one owner at a time). That serialization exists solely in SUPERVISED mode
|
|
67
|
+
# (the actor loop lives on the supervisor). Two concurrent send_message on the
|
|
68
|
+
# same session_id OUTSIDE that path (e.g. calling append_messages directly, or
|
|
69
|
+
# a non-supervised deployment) would interleave read/set and LOSE messages —
|
|
70
|
+
# there is no compare-and-swap here. Route same-session writes through the
|
|
71
|
+
# SessionActor; see session_actor.rb.
|
|
72
|
+
def append_messages(id, messages)
|
|
73
|
+
record = fetch!(id)
|
|
74
|
+
incoming = (messages.is_a?(Hash) ? [messages] : Array(messages))
|
|
75
|
+
.map { |msg| stamp(deep_stringify(msg)) }
|
|
76
|
+
record["messages"] += incoming
|
|
77
|
+
record["updated_at"] = timestamp
|
|
78
|
+
@store.set(SCOPE, key_for(id), record)
|
|
79
|
+
to_session(record)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# -> Session (SHALLOW merge: an existing nested key is replaced wholesale,
|
|
83
|
+
# not merged). NotFoundError if absent.
|
|
84
|
+
def update_vars(id, vars)
|
|
85
|
+
record = fetch!(id)
|
|
86
|
+
record["vars"] = record["vars"].merge(deep_stringify(vars))
|
|
87
|
+
record["updated_at"] = timestamp
|
|
88
|
+
@store.set(SCOPE, key_for(id), record)
|
|
89
|
+
to_session(record)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# -> bool (delegates to the backend: false for a nonexistent id)
|
|
93
|
+
def delete(id)
|
|
94
|
+
@store.delete(SCOPE, key_for(id))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# -> enumerates ids without the "session:" prefix. Without a block,
|
|
98
|
+
# returns an Enumerator.
|
|
99
|
+
def each_id
|
|
100
|
+
return enum_for(:each_id) unless block_given?
|
|
101
|
+
|
|
102
|
+
@store.list(SCOPE, KEY_PREFIX).each do |key|
|
|
103
|
+
yield key.delete_prefix(KEY_PREFIX)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def key_for(id)
|
|
110
|
+
"#{KEY_PREFIX}#{id}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Loads the raw record; NotFoundError if absent (nonexistent session ->
|
|
114
|
+
# HTTP 404). Backend errors (StoreError) propagate without re-wrapping.
|
|
115
|
+
def fetch!(id)
|
|
116
|
+
record = @store.get(SCOPE, key_for(id))
|
|
117
|
+
raise Insika::NotFoundError, "session not found: #{id}" if record.nil?
|
|
118
|
+
|
|
119
|
+
record
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def to_session(record)
|
|
123
|
+
Session.new(
|
|
124
|
+
id: record["id"],
|
|
125
|
+
messages: record["messages"],
|
|
126
|
+
vars: record["vars"],
|
|
127
|
+
memory_refs: record["memory_refs"],
|
|
128
|
+
created_at: record["created_at"],
|
|
129
|
+
updated_at: record["updated_at"]
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Stamps "at" (ISO8601 UTC) on the message when absent; preserves whatever is provided.
|
|
134
|
+
def stamp(message)
|
|
135
|
+
message["at"] ||= timestamp
|
|
136
|
+
message
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def timestamp
|
|
140
|
+
Time.now.utc.iso8601
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# GENERAL deploy settings: timeouts, streaming and
|
|
5
|
+
# compaction. A single record in the ConfigStore (scope "settings", key "general").
|
|
6
|
+
# A read always returns the DEFAULTS overlaid by whatever was authored — so
|
|
7
|
+
# a brand-new deploy (empty store) already responds with coherent config, and the Studio
|
|
8
|
+
# only persists the delta. Shallow merge at the top, deep in `compaction` (sub-hash).
|
|
9
|
+
#
|
|
10
|
+
# Not to be confused with the transport `CONFIG` (bind/port/token, ENV+freeze at
|
|
11
|
+
# boot): this is editable RUNTIME config, durable in the same backend.
|
|
12
|
+
class SettingsStore
|
|
13
|
+
SCOPE = "settings"
|
|
14
|
+
KEY = "general"
|
|
15
|
+
|
|
16
|
+
# STRICT config, settings layer (item 23 / §8.1 — "no silent config compat: every
|
|
17
|
+
# schema migration explicit"). The settings record carries a `schema_version`;
|
|
18
|
+
# every shape change is a numbered migration here, applied ONLY by the explicit
|
|
19
|
+
# `migrate!` (Studio settings saves never silently reinterpret old-shaped data).
|
|
20
|
+
# v1 is the baseline: a pre-versioning record (no `schema_version`) reads as 0 and
|
|
21
|
+
# `insika doctor --fix` stamps it to 1. Add a real change as MIGRATIONS[2] = proc
|
|
22
|
+
# and bump SCHEMA_VERSION.
|
|
23
|
+
SCHEMA_VERSION = 1
|
|
24
|
+
MIGRATIONS = {}.freeze # target_version(Integer) => ->(record_hash){ migrated_hash }
|
|
25
|
+
|
|
26
|
+
DEFAULTS = {
|
|
27
|
+
"schema_version" => SCHEMA_VERSION,
|
|
28
|
+
"streaming" => true,
|
|
29
|
+
"request_timeout" => 120,
|
|
30
|
+
"max_retries" => 2,
|
|
31
|
+
"turn_timeout" => 120,
|
|
32
|
+
"tool_timeout" => 30,
|
|
33
|
+
"compaction" => { "enabled" => false, "keep_last" => 20 },
|
|
34
|
+
# LLM config v2 (§10). Platform-wide model layer, resolved by the
|
|
35
|
+
# ModelResolver under an agent that pins no model of its own:
|
|
36
|
+
# default_model/default_provider -> the platform default (Chat > Agent > HERE)
|
|
37
|
+
# fallback_models -> ordered chain ["provider/model" | "model", ...] tried
|
|
38
|
+
# when the primary is NOT a user pin (source semantics)
|
|
39
|
+
# utility_model -> slot for cheap internal tasks (titles, distillation,
|
|
40
|
+
# compaction); reserved for later wiring.
|
|
41
|
+
"default_model" => nil,
|
|
42
|
+
"default_provider" => nil,
|
|
43
|
+
"fallback_models" => [],
|
|
44
|
+
"utility_model" => nil,
|
|
45
|
+
# Reasoning control (§10, 4-layer: Chat > Agent > Model > Global). `thinking`
|
|
46
|
+
# is the GLOBAL default (off/on/low/medium/high; nil = provider default);
|
|
47
|
+
# `model_params` is the PER-MODEL layer, a map "<provider/model>"|"<model>" ->
|
|
48
|
+
# { "thinking" => ... }. Both resolved by the ModelResolver.
|
|
49
|
+
"thinking" => nil,
|
|
50
|
+
"model_params" => {},
|
|
51
|
+
# Evals (RFC-0008, panel by RFC-0013 §3.9). The GRADERS are platform config, so
|
|
52
|
+
# the operator picks them in the Studio instead of remembering a CLI flag:
|
|
53
|
+
# judges -> [{ "model" =>, "provider" => }, …]. [] = deterministic
|
|
54
|
+
# asserts only (rubric'd cases read as judge_pending).
|
|
55
|
+
# aggregate -> median | mean | min — how the panel's scores become the one
|
|
56
|
+
# the report and the baseline read.
|
|
57
|
+
# min_agreement -> fraction of judges that must pass for the case to pass.
|
|
58
|
+
# quorum -> samples per judge (variance), on top of the panel.
|
|
59
|
+
# tolerance -> max judge-score drop before it counts as a regression.
|
|
60
|
+
# Additive key: reads overlay DEFAULTS, so no numbered migration is due (only a
|
|
61
|
+
# later SHAPE change to this key would earn one).
|
|
62
|
+
"evals" => {
|
|
63
|
+
"judges" => [],
|
|
64
|
+
"aggregate" => "median",
|
|
65
|
+
"min_agreement" => 0.5,
|
|
66
|
+
"quorum" => 1,
|
|
67
|
+
"tolerance" => 0.05
|
|
68
|
+
},
|
|
69
|
+
# Edge limits (item 33 / §12 G7) — the platform layer of the EdgeLimiter.
|
|
70
|
+
# nil/0 = off (opt-in). chat_rate_limit = turn attempts per chat per
|
|
71
|
+
# chat_rate_window (s); agent_token_ceiling = total tokens per agent per
|
|
72
|
+
# agent_token_window (s). limit_response overrides the safe reply.
|
|
73
|
+
# Per-agent overrides live in profile.limits (same keys, sans windows).
|
|
74
|
+
"edge" => {
|
|
75
|
+
"chat_rate_limit" => nil,
|
|
76
|
+
"chat_rate_window" => 60,
|
|
77
|
+
"agent_token_ceiling" => nil,
|
|
78
|
+
"agent_token_window" => 86_400,
|
|
79
|
+
"limit_response" => nil
|
|
80
|
+
}
|
|
81
|
+
}.freeze
|
|
82
|
+
|
|
83
|
+
def initialize(config_store:)
|
|
84
|
+
@cs = config_store
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# -> Hash (defaults overlaid by the authored values). String keys (Store contract).
|
|
88
|
+
def get
|
|
89
|
+
deep_merge(DEFAULTS, stored)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Merges the patch over the current one and persists. -> Hash (resulting settings).
|
|
93
|
+
# Unknown keys are preserved (the Studio decides the screen's schema).
|
|
94
|
+
def update(patch)
|
|
95
|
+
merged = deep_merge(get, stringify(patch || {}))
|
|
96
|
+
@cs.put(SCOPE, KEY, merged)
|
|
97
|
+
merged
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# RAW persisted schema version (bypasses the DEFAULTS overlay, which would always
|
|
101
|
+
# report the current one). nil = no settings persisted yet (fresh deploy — nothing
|
|
102
|
+
# to migrate); an Integer otherwise, 0 for a pre-versioning record.
|
|
103
|
+
def stored_schema_version
|
|
104
|
+
raw = stored
|
|
105
|
+
return nil if raw.empty?
|
|
106
|
+
|
|
107
|
+
Integer(raw["schema_version"] || 0)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Which numbered migrations still need to run. [] when fresh or already current.
|
|
111
|
+
def pending_migrations
|
|
112
|
+
from = stored_schema_version
|
|
113
|
+
return [] if from.nil? || from >= SCHEMA_VERSION
|
|
114
|
+
|
|
115
|
+
((from + 1)..SCHEMA_VERSION).to_a
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Applies the pending migrations EXPLICITLY (in order) and stamps the version. No-op
|
|
119
|
+
# when fresh or already current. -> resulting schema_version (Integer).
|
|
120
|
+
def migrate!
|
|
121
|
+
data = stored
|
|
122
|
+
return SCHEMA_VERSION if data.empty?
|
|
123
|
+
|
|
124
|
+
pending_migrations.each do |version|
|
|
125
|
+
migration = MIGRATIONS[version]
|
|
126
|
+
data = stringify(migration.call(data)) if migration
|
|
127
|
+
end
|
|
128
|
+
data["schema_version"] = SCHEMA_VERSION
|
|
129
|
+
@cs.put(SCOPE, KEY, data)
|
|
130
|
+
SCHEMA_VERSION
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def stored
|
|
136
|
+
@cs.get(SCOPE, KEY) || {}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def deep_merge(base, override)
|
|
140
|
+
base.merge(override) do |_key, a, b|
|
|
141
|
+
a.is_a?(Hash) && b.is_a?(Hash) ? deep_merge(a, b) : b
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def stringify(obj)
|
|
146
|
+
case obj
|
|
147
|
+
when Hash then obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = stringify(v) }
|
|
148
|
+
when Array then obj.map { |v| stringify(v) }
|
|
149
|
+
when Symbol then obj.to_s
|
|
150
|
+
else obj
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# RFC-0016 A3 — shutdown is a drain, not a kill (docs/DEPLOY.md, process model
|
|
5
|
+
# item 4). The serving arms install this around the Executor. On the first
|
|
6
|
+
# SIGTERM/SIGINT the process stops accepting new turns (`Executor#begin_drain!`
|
|
7
|
+
# — a turn arriving mid-drain is left `:queued` for the next boot's recovery)
|
|
8
|
+
# and waits up to `timeout` seconds for the in-flight ones; only then does the
|
|
9
|
+
# ordinary stop proceed. A second signal skips the wait — the operator insisting
|
|
10
|
+
# means now. Whatever the deadline abandons dies `:running` with the process and
|
|
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, RFC-0006).
|
|
13
|
+
#
|
|
14
|
+
# Mechanics, because trap context is narrow: the handler writes ONE byte into a
|
|
15
|
+
# self-pipe and returns. A plain watcher THREAD — not a fiber: at install time
|
|
16
|
+
# the serving reactor may not exist yet, and the drain must not depend on it —
|
|
17
|
+
# blocks on that pipe, runs the drain, and then delivers the stop the trap
|
|
18
|
+
# withheld by raising Interrupt in the serving thread. That is exactly the
|
|
19
|
+
# exception async-container's own trap would have raised, so everything
|
|
20
|
+
# downstream (Falcon worker teardown, reactor close, Litestream's final sync)
|
|
21
|
+
# is unchanged; this class only buys the turns time before it.
|
|
22
|
+
class Shutdown
|
|
23
|
+
DEFAULT_TIMEOUT = 20 # seconds — the number DEPLOY.md records and entrypoint.sh builds on
|
|
24
|
+
POLL = 0.05
|
|
25
|
+
|
|
26
|
+
# The one-call form the serving arms use: resolves the deadline, sets the
|
|
27
|
+
# traps, parks the watcher. The CALLING thread is captured as the stop target
|
|
28
|
+
# — install from the thread that runs the server.
|
|
29
|
+
#
|
|
30
|
+
# `executors:` (RFC-0017 A4) drains N graphs on one signal. Signals are a
|
|
31
|
+
# PROCESS concern, and `Signal.trap` keeps only the last handler — so a second
|
|
32
|
+
# `install` per graph would silently leave the earlier graphs dying mid-turn.
|
|
33
|
+
# The host installs ONCE, naming every graph it embedded. `executor:` is the
|
|
34
|
+
# single-graph sugar every serving arm still uses.
|
|
35
|
+
def self.install(executor: nil, executors: nil, timeout: nil, logger: $stdout, signals: %w[INT TERM])
|
|
36
|
+
new(executors: executors || executor, timeout: timeout || default_timeout,
|
|
37
|
+
logger: logger).install!(signals)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# INSIKA_DRAIN_TIMEOUT (validated by EnvSchema); absent/garbage -> the default.
|
|
41
|
+
def self.default_timeout
|
|
42
|
+
Integer(EnvSchema.read("INSIKA_DRAIN_TIMEOUT").to_s)
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
DEFAULT_TIMEOUT
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# `interrupt` is the injectable stop delivery (specs); the default raises
|
|
48
|
+
# Interrupt in `target`, the thread that installed the traps.
|
|
49
|
+
def initialize(executor: nil, executors: nil, timeout:, logger: $stdout,
|
|
50
|
+
target: Thread.current, interrupt: nil)
|
|
51
|
+
@executors = Array(executors || executor)
|
|
52
|
+
@timeout = timeout
|
|
53
|
+
@logger = logger
|
|
54
|
+
@target = target
|
|
55
|
+
@interrupt = interrupt || -> { @target.raise(Interrupt) }
|
|
56
|
+
@reader, @writer = IO.pipe
|
|
57
|
+
@signaled = false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
attr_reader :timeout
|
|
61
|
+
|
|
62
|
+
def install!(signals = %w[INT TERM])
|
|
63
|
+
signals.each { |sig| Signal.trap(sig) { signal_received } }
|
|
64
|
+
@thread = Thread.new { watch }
|
|
65
|
+
@thread.name = "insika-shutdown"
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Runs in TRAP context: a flag flip and a pipe write, nothing else — except
|
|
70
|
+
# when the drain is already underway, where a repeated signal means "stop
|
|
71
|
+
# waiting" and the Interrupt is delivered immediately.
|
|
72
|
+
def signal_received
|
|
73
|
+
return @interrupt.call if @signaled
|
|
74
|
+
|
|
75
|
+
@signaled = true
|
|
76
|
+
begin
|
|
77
|
+
@writer.write_nonblock("!")
|
|
78
|
+
rescue IOError, SystemCallError
|
|
79
|
+
nil # pipe gone (process already unwinding): nothing left to schedule
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The watcher's whole life: parked on the pipe until the first signal, then
|
|
84
|
+
# drain and hand the stop back to the normal path.
|
|
85
|
+
def watch
|
|
86
|
+
@reader.read(1)
|
|
87
|
+
drain
|
|
88
|
+
@interrupt.call
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Closes the intake and waits for the in-flight turns, bounded by the
|
|
92
|
+
# deadline. With N executors the intake of EVERY one closes first, before any
|
|
93
|
+
# waiting: draining them in sequence would let graph B keep taking turns while
|
|
94
|
+
# graph A spends the deadline. -> { drained: bool, abandoned: [task ids] }
|
|
95
|
+
def drain
|
|
96
|
+
@executors.each(&:begin_drain!)
|
|
97
|
+
log("shutdown: draining — #{in_flight.size} turn(s) in flight, deadline #{@timeout}s")
|
|
98
|
+
deadline = monotonic + @timeout
|
|
99
|
+
sleep(POLL) until in_flight.empty? || monotonic >= deadline
|
|
100
|
+
|
|
101
|
+
abandoned = in_flight
|
|
102
|
+
if abandoned.empty?
|
|
103
|
+
log("shutdown: drained clean")
|
|
104
|
+
else
|
|
105
|
+
log("shutdown: deadline reached — abandoning #{abandoned.size} turn(s); " \
|
|
106
|
+
"recovery replays them at the next boot")
|
|
107
|
+
end
|
|
108
|
+
{ drained: abandoned.empty?, abandoned: abandoned }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# The turns still running across every graph this shutdown owns.
|
|
114
|
+
def in_flight = @executors.flat_map(&:in_flight)
|
|
115
|
+
|
|
116
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
117
|
+
|
|
118
|
+
# Observability only: a logger failure must never alter the drain.
|
|
119
|
+
def log(message)
|
|
120
|
+
@logger&.puts(message)
|
|
121
|
+
rescue StandardError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
Skill = Data.define(:name, :description, :path, :body)
|
|
15
|
+
|
|
16
|
+
# roots ordered by PRECEDENCE (highest first): workspace, managed,
|
|
17
|
+
# bundled. Same name in more than one root: the first wins.
|
|
18
|
+
#
|
|
19
|
+
# store (optional): a SkillStore with the skills AUTHORED in the Studio.
|
|
20
|
+
# They overlay the on-disk ones (seed) — the Store wins, it is the source of truth.
|
|
21
|
+
# Nil = disk-only behavior, zero regression.
|
|
22
|
+
def initialize(roots, store: nil)
|
|
23
|
+
@roots = Array(roots)
|
|
24
|
+
@store = store
|
|
25
|
+
@skills = load_all
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def all
|
|
29
|
+
@skills.values
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def find(name)
|
|
33
|
+
@skills[name.to_s]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Reloads from disk + Store and SWAPS the index atomically: an
|
|
37
|
+
# authored/edited skill takes effect without a restart. A turn in progress
|
|
38
|
+
# captured @skills at dispatch, so it does not see the swap mid-flight.
|
|
39
|
+
def reload
|
|
40
|
+
@skills = load_all
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Per-agent allowlist: nil -> all | [] -> none | [names] -> subset.
|
|
45
|
+
def effective(skills_policy)
|
|
46
|
+
Allowlist.filter(all, skills_policy) { |s| s.name }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Level 1: compact list injected into the system prompt. Metadata only.
|
|
50
|
+
# Receives the set already filtered by the agent.
|
|
51
|
+
def format_for_prompt(skills = all)
|
|
52
|
+
return "" if skills.empty?
|
|
53
|
+
|
|
54
|
+
entries = skills.map do |s|
|
|
55
|
+
%( <skill name="#{s.name}">#{s.description}</skill>)
|
|
56
|
+
end.join("\n")
|
|
57
|
+
|
|
58
|
+
<<~PROMPT.strip
|
|
59
|
+
<available_skills>
|
|
60
|
+
#{entries}
|
|
61
|
+
</available_skills>
|
|
62
|
+
|
|
63
|
+
Before acting on a task that matches a skill above, call the
|
|
64
|
+
`load_skill` tool with its name to load the complete instructions.
|
|
65
|
+
PROMPT
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def load_all
|
|
71
|
+
found = {}
|
|
72
|
+
@roots.each do |root|
|
|
73
|
+
Dir.glob(File.join(root, "**", "SKILL.md")).sort.each do |file|
|
|
74
|
+
skill = parse_content(File.read(file, encoding: "UTF-8"), path: file)
|
|
75
|
+
next unless skill
|
|
76
|
+
|
|
77
|
+
found[skill.name] ||= skill # precedence: first root wins
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
overlay_store(found)
|
|
81
|
+
found
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Store skills overlay the on-disk ones (authored > seed). Sentinel path
|
|
85
|
+
# "store:<name>" — not a real file (load_skill uses `body`, not the path).
|
|
86
|
+
def overlay_store(found)
|
|
87
|
+
return unless @store
|
|
88
|
+
|
|
89
|
+
@store.all.each do |name, content|
|
|
90
|
+
skill = parse_content(content.to_s, path: "store:#{name}")
|
|
91
|
+
found[skill.name] = skill if skill # Store wins
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def parse_content(raw, path:)
|
|
96
|
+
match = raw.match(/\A---\s*\n(.*?)\n---\s*\n(.*)\z/m)
|
|
97
|
+
return nil unless match
|
|
98
|
+
|
|
99
|
+
# Tolerant frontmatter: real packs have `: ` in the description prose, which
|
|
100
|
+
# strict YAML rejected (the pack would not load).
|
|
101
|
+
meta = Insika::Frontmatter.parse(match[1])
|
|
102
|
+
name = meta["name"]
|
|
103
|
+
return nil unless name
|
|
104
|
+
|
|
105
|
+
Skill.new(
|
|
106
|
+
name: name.to_s,
|
|
107
|
+
description: meta["description"].to_s,
|
|
108
|
+
path: path,
|
|
109
|
+
body: match[2].strip
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# AUTHORED shared skills.
|
|
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
|
+
# One record per skill in the ConfigStore (scope "skills"):
|
|
12
|
+
# { "content" => "<entire SKILL.md>",
|
|
13
|
+
# "updated_at" => iso8601,
|
|
14
|
+
# "history" => [ { "content" =>, "at" => }, ... ] }
|
|
15
|
+
#
|
|
16
|
+
# The key is the skill's canonical name (the same as in the frontmatter). Versions like
|
|
17
|
+
# the AgentFileStore.
|
|
18
|
+
class SkillStore
|
|
19
|
+
SCOPE = "skills"
|
|
20
|
+
HISTORY_MAX = 20
|
|
21
|
+
|
|
22
|
+
def initialize(config_store:)
|
|
23
|
+
@cs = config_store
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# -> String | nil (complete SKILL.md).
|
|
27
|
+
def get(name)
|
|
28
|
+
record(name)&.fetch("content", nil)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# -> [String] names, lexicographic order.
|
|
32
|
+
def names = @cs.keys(SCOPE)
|
|
33
|
+
|
|
34
|
+
# -> { name => content } of all authored skills.
|
|
35
|
+
def all
|
|
36
|
+
@cs.keys(SCOPE).each_with_object({}) { |n, acc| acc[n] = get(n) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Writes (upsert). create_only refuses to overwrite. -> Hash (the stored record).
|
|
40
|
+
def write(name, content, create_only: false)
|
|
41
|
+
key = name.to_s
|
|
42
|
+
current = @cs.get(SCOPE, key)
|
|
43
|
+
raise Insika::ValidationError, "skill '#{key}' already exists" if create_only && current
|
|
44
|
+
|
|
45
|
+
rec = build_record(content.to_s, current)
|
|
46
|
+
@cs.put(SCOPE, key, rec)
|
|
47
|
+
rec
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# -> bool (did it exist?).
|
|
51
|
+
def delete(name) = @cs.delete(SCOPE, name.to_s)
|
|
52
|
+
|
|
53
|
+
# -> [ { "content" =>, "at" => } ] most recent first.
|
|
54
|
+
def versions(name) = record(name)&.fetch("history", []) || []
|
|
55
|
+
|
|
56
|
+
# Restores version `index` as the current content (a new write). -> Hash.
|
|
57
|
+
def restore(name, index)
|
|
58
|
+
hist = versions(name)
|
|
59
|
+
i = Integer(index)
|
|
60
|
+
raise Insika::NotFoundError, "skill '#{name}' not found" unless record(name)
|
|
61
|
+
raise Insika::ValidationError, "version #{index} does not exist" if i.negative? || i >= hist.length
|
|
62
|
+
|
|
63
|
+
write(name, hist[i]["content"])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def record(name) = @cs.get(SCOPE, name.to_s)
|
|
69
|
+
|
|
70
|
+
def build_record(content, current)
|
|
71
|
+
history = current ? current.fetch("history", []) : []
|
|
72
|
+
if current
|
|
73
|
+
history = [{ "content" => current["content"], "at" => current["updated_at"] }] + history
|
|
74
|
+
history = history.first(HISTORY_MAX)
|
|
75
|
+
end
|
|
76
|
+
{ "content" => content, "updated_at" => Time.now.utc.iso8601, "history" => history }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|