insika 0.2.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +325 -17
- data/README.md +52 -11
- data/bin/insika +895 -8
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +229 -163
- data/docs/API.md +56 -0
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +111 -21
- data/docs/CONTEXT.md +132 -19
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +75 -4
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +146 -2
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +116 -0
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +16 -10
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +65 -6
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +17 -12
- data/docs/RELEASING.md +27 -0
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +6 -6
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +79 -2
- data/docs/SKILLS.md +13 -4
- data/docs/SOAK.md +127 -0
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +220 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +115 -0
- data/docs/improve.md +20 -0
- data/docs/index.md +9 -6
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +202 -10
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +119 -14
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +105 -2
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +107 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +57 -23
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +889 -11
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +69 -82
- data/lib/insika/dsl/server_boot.rb +92 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +186 -5
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +25 -3
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +29 -6
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +869 -76
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +198 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +17 -2
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +382 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +7 -2
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +22 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +29 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +316 -0
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +247 -17
- data/lib/insika/server/rack_app.rb +21 -1
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +16 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1437 -54
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +23 -23
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +308 -10
- data/lib/insika/studio/nav_icons.rb +19 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +62 -278
- data/lib/insika/studio/views/agents.erb +71 -54
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +6 -3
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +106 -66
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +15 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +233 -124
- data/lib/insika/studio/views/settings.erb +41 -1
- data/lib/insika/studio/views/skills.erb +1 -2
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +24 -10
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +38 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +58 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +47 -11
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +89 -0
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +49 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +472 -13
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +149 -3
- metadata +181 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Context
|
|
5
|
+
module Providers
|
|
6
|
+
# Read path for the session briefing: the per-session
|
|
7
|
+
# working-state the agent keeps and asks for. Thin adapter over the
|
|
8
|
+
# SessionStore, same pattern as Memory: one `:system` fragment,
|
|
9
|
+
# deterministic. The MISSING list is rendered, never implied — that list
|
|
10
|
+
# is what stops the model re-asking for a field already given.
|
|
11
|
+
class Briefing < ContextProvider
|
|
12
|
+
def initialize(session_store:)
|
|
13
|
+
@session_store = session_store
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Stable id -> the context-trace category "briefing".
|
|
17
|
+
def id = "briefing"
|
|
18
|
+
|
|
19
|
+
# Pack-declared: no briefing_fields -> no provider (the Builder still
|
|
20
|
+
# applies the `context_providers` allowlist on top — two gates, like Memory).
|
|
21
|
+
def enabled_for?(profile)
|
|
22
|
+
fields = profile.respond_to?(:briefing_fields) ? profile.briefing_fields : nil
|
|
23
|
+
!Array(fields).empty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# required? == false (default): a store failure degrades via the Builder's
|
|
27
|
+
# warning path, never aborts the turn.
|
|
28
|
+
def call(request)
|
|
29
|
+
session = request.respond_to?(:session) ? request.session : nil
|
|
30
|
+
return [] if session.nil? # one-shot turns have no briefing
|
|
31
|
+
|
|
32
|
+
briefing = briefing_for(session)
|
|
33
|
+
fields = briefing["fields"] || {}
|
|
34
|
+
declared = Array(request.profile.briefing_fields)
|
|
35
|
+
return [] if declared.empty? # defensive; enabled_for? already gates
|
|
36
|
+
|
|
37
|
+
block = format_block(declared, fields, briefing["next_step"])
|
|
38
|
+
return [] if block.nil?
|
|
39
|
+
|
|
40
|
+
[ContextFragment.build(content: block, placement: :system,
|
|
41
|
+
priority: Context::Priority::BRIEFING,
|
|
42
|
+
source: id)]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Re-reads the briefing from the store, like the Session provider: the
|
|
48
|
+
# persisted record is the source of truth, not the request's turn-start
|
|
49
|
+
# snapshot. A read failure propagates to the Builder, which degrades it
|
|
50
|
+
# to a :provider_warning (required? == false).
|
|
51
|
+
def briefing_for(session)
|
|
52
|
+
@session_store.find(session.id)&.briefing || {}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Byte contract (the specs assert this shape):
|
|
56
|
+
# <briefing>
|
|
57
|
+
# known:
|
|
58
|
+
# size: M
|
|
59
|
+
# still missing: delivery_day
|
|
60
|
+
# next step: send the payment link tomorrow at 10
|
|
61
|
+
# </briefing>
|
|
62
|
+
# Rules: `known` renders only when at least one declared field has a
|
|
63
|
+
# stored value; `still missing` renders every declared field with no
|
|
64
|
+
# stored value (including the all-missing case — that is the block's
|
|
65
|
+
# job); `next step` renders only when non-nil; stored keys NOT in the
|
|
66
|
+
# declaration are never rendered (they stay in the store and reappear if
|
|
67
|
+
# the pack re-declares them).
|
|
68
|
+
def format_block(declared, fields, next_step)
|
|
69
|
+
known = declared.filter_map do |name|
|
|
70
|
+
" #{name}: #{flatten(fields[name])}" if Coercion.present?(fields[name])
|
|
71
|
+
end
|
|
72
|
+
missing = declared.reject { |name| Coercion.present?(fields[name]) }
|
|
73
|
+
|
|
74
|
+
lines = []
|
|
75
|
+
lines << "known:" unless known.empty?
|
|
76
|
+
lines.concat(known)
|
|
77
|
+
lines << "still missing: #{missing.join(', ')}" unless missing.empty?
|
|
78
|
+
lines << "next step: #{flatten(next_step)}" if Coercion.present?(next_step)
|
|
79
|
+
return nil if lines.empty?
|
|
80
|
+
|
|
81
|
+
<<~BLOCK.strip
|
|
82
|
+
<briefing>
|
|
83
|
+
#{lines.join("\n")}
|
|
84
|
+
</briefing>
|
|
85
|
+
BLOCK
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# utf8 the value and flatten newlines/whitespace so a value can never
|
|
89
|
+
# break the block's line structure.
|
|
90
|
+
def flatten(value)
|
|
91
|
+
Coercion.utf8(value.to_s).gsub(/\s+/, " ").strip
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Context
|
|
5
|
+
module Providers
|
|
6
|
+
# Level 1 (progressive disclosure) of what the engine has LEARNED, as
|
|
7
|
+
# opposed to what a human curated (Skill) or the model wrote mid-turn
|
|
8
|
+
# (Memory). Retrieval is per-message and dynamic, so — like
|
|
9
|
+
# `SkillTrigger`, unlike the static `CatalogProvider` subclasses — this
|
|
10
|
+
# builds its `<knowledge>` block directly in `call`, never a fixed list.
|
|
11
|
+
class Knowledge < ContextProvider
|
|
12
|
+
def initialize(store:)
|
|
13
|
+
@store = store
|
|
14
|
+
# One Index PER TYPE, built once and reused for every agent/turn —
|
|
15
|
+
# never per call. This provider instance itself lives for the
|
|
16
|
+
# process's lifetime (built once at boot, see wiring), so an
|
|
17
|
+
# Index rebuilt fresh each call would throw away its own read
|
|
18
|
+
# cache (Index::Scan's dominant cost is re-parsing YAML
|
|
19
|
+
# frontmatter; measured, not assumed) on every single turn.
|
|
20
|
+
# Keyed by the config's `index` string so a future FTS5 agent
|
|
21
|
+
# gets its own instance, never Scan's.
|
|
22
|
+
@indexes = Hash.new { |h, index_name| h[index_name] = Insika::Knowledge::Index.build({ "index" => index_name }, store: @store) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Per-agent opt-in (`knowledge.retrieve`), like Memory's `profile.memory`.
|
|
26
|
+
def enabled_for?(profile) = !!(profile.knowledge && Coercion.truthy?(profile.knowledge["retrieve"]))
|
|
27
|
+
|
|
28
|
+
# required? == false (default): a store failure degrades to a
|
|
29
|
+
# :provider_warning, never aborts the turn.
|
|
30
|
+
def call(request)
|
|
31
|
+
config = request.profile.knowledge
|
|
32
|
+
return [] unless config
|
|
33
|
+
|
|
34
|
+
top_k = positive_int(config["top_k"]) || 5
|
|
35
|
+
index = @indexes[config["index"].to_s]
|
|
36
|
+
matches = index.search(request.profile.id, tenant: request.tenant,
|
|
37
|
+
query: request.message.to_s, top_k: top_k)
|
|
38
|
+
return [] if matches.empty?
|
|
39
|
+
|
|
40
|
+
hits = matches.map { |c| [c, "top-K match"] } +
|
|
41
|
+
expand_links(matches, request, top_k).map { |c| [c, "one-hop link"] }
|
|
42
|
+
|
|
43
|
+
[ContextFragment.build(
|
|
44
|
+
content: format_block(hits), placement: :system,
|
|
45
|
+
priority: Context::Priority::KNOWLEDGE, source: id,
|
|
46
|
+
labels: hits.map { |c, reason| { "name" => c[:name], "reason" => reason } }
|
|
47
|
+
)]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def positive_int(value)
|
|
53
|
+
n = value.to_i
|
|
54
|
+
n.positive? ? n : nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ONE level, deliberately — the same "cannot work without" reasoning
|
|
58
|
+
# `SkillTrigger#companions` applies to a skill's declared companions:
|
|
59
|
+
# a transitive walk would make a cycle a hang and a chain a budget
|
|
60
|
+
# blowout. Newly-discovered concepts (not already in the top-K) are
|
|
61
|
+
# capped at top_k again — "that one hop is the whole graph benefit at
|
|
62
|
+
# ~1% of the graph cost", not a second unbounded retrieval.
|
|
63
|
+
def expand_links(matches, request, top_k)
|
|
64
|
+
known = matches.map { |c| c[:name] }
|
|
65
|
+
discovered = []
|
|
66
|
+
matches.each do |concept|
|
|
67
|
+
Insika::Knowledge::Concept.links(concept[:body]).each do |name|
|
|
68
|
+
next if known.include?(name) || discovered.any? { |d| d[:name] == name }
|
|
69
|
+
|
|
70
|
+
found = fetch(request, name)
|
|
71
|
+
discovered << found if found
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
discovered.first(top_k)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def fetch(request, name)
|
|
78
|
+
raw = @store.get(request.profile.id, name, tenant: request.tenant)
|
|
79
|
+
raw && Insika::Knowledge::Concept.parse(raw)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Level 1 only — name/description/confidence/provenance, never the
|
|
83
|
+
# body (that is `load_knowledge`'s job). The instruction is the exact
|
|
84
|
+
# lesson the knowledge-adoption experiment drew: a polite "when to
|
|
85
|
+
# use" scored near zero; an explicit, ordered rule naming the tool
|
|
86
|
+
# held up. Present only when there is something to point at.
|
|
87
|
+
def format_block(hits)
|
|
88
|
+
entries = hits.map do |c, _reason|
|
|
89
|
+
%( <concept name="#{c[:name]}" confidence="#{format('%.2f', c[:confidence])}" ) +
|
|
90
|
+
%(provenance="#{c[:provenance]}">#{c[:description]}</concept>)
|
|
91
|
+
end.join("\n")
|
|
92
|
+
|
|
93
|
+
<<~BLOCK.strip
|
|
94
|
+
<knowledge>
|
|
95
|
+
#{entries}
|
|
96
|
+
</knowledge>
|
|
97
|
+
|
|
98
|
+
If the customer's question needs more than the summary above, call
|
|
99
|
+
`load_knowledge("name")` FIRST — before any other lookup for that
|
|
100
|
+
topic. This is learned from past conversations, not official
|
|
101
|
+
policy: never state a `provenance="observed"` concept to the
|
|
102
|
+
customer as a guarantee.
|
|
103
|
+
BLOCK
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -20,7 +20,7 @@ module Insika
|
|
|
20
20
|
# required? == false (default): a failure (store unavailable) becomes a
|
|
21
21
|
# :provider_warning + graceful degradation — never aborts the turn.
|
|
22
22
|
def call(request)
|
|
23
|
-
tenant =
|
|
23
|
+
tenant = memory_scope(request)
|
|
24
24
|
facts = @store.facts(tenant: tenant)
|
|
25
25
|
notes = @store.notes(tenant: tenant, limit: @notes_limit)
|
|
26
26
|
return [] if facts.empty? && notes.empty?
|
|
@@ -33,15 +33,24 @@ module Insika
|
|
|
33
33
|
|
|
34
34
|
private
|
|
35
35
|
|
|
36
|
-
# Engine memory scope:
|
|
37
|
-
# (
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
|
|
36
|
+
# Engine memory scope (WS8): the request's CUSTOMER-scoped cell
|
|
37
|
+
# ("[tenant:]customer" — engine-owner memory is per customer, never per
|
|
38
|
+
# tenant) wins; otherwise an EXPLICIT tenant from the Command (the
|
|
39
|
+
# multi-merchant override); otherwise the SESSION (=chat), MARKED like
|
|
40
|
+
# the write path ("chat:<session id>" — : a session cell is
|
|
41
|
+
# never a bare cell, so the drill cannot read a conversation as a
|
|
42
|
+
# customer). No session (one-shot) and no tenant -> nil (MemoryStore
|
|
43
|
+
# applies _default). Symmetric to the write path (`state.tenant` in the
|
|
44
|
+
# Executor).
|
|
45
|
+
def memory_scope(request)
|
|
46
|
+
scoped = request.respond_to?(:memory_scope) ? request.memory_scope : nil
|
|
47
|
+
return scoped if scoped
|
|
48
|
+
|
|
41
49
|
explicit = request.respond_to?(:tenant) ? request.tenant : nil
|
|
42
50
|
return explicit if explicit
|
|
43
51
|
|
|
44
|
-
request.respond_to?(:session) ? request.session
|
|
52
|
+
session = request.respond_to?(:session) ? request.session : nil
|
|
53
|
+
session && session.id ? "#{Insika::MemoryStore::SESSION_TAG}:#{session.id}" : nil
|
|
45
54
|
end
|
|
46
55
|
|
|
47
56
|
# Passive <memory> (no instruction — the HOW of writing lives in the `remember` tool).
|
|
@@ -8,30 +8,58 @@ module Insika
|
|
|
8
8
|
# a WRONG agent, not a degraded one. prompt_refs: priority 90 pinned
|
|
9
9
|
# fragments, from the PromptCatalog (catalog defaults to nil).
|
|
10
10
|
#
|
|
11
|
-
# PER-AGENT identity
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# (
|
|
15
|
-
#
|
|
16
|
-
# `
|
|
11
|
+
# PER-AGENT identity, from `profile.prompt_files`/`base_prompt` only — an
|
|
12
|
+
# agent that declares neither has no identity here, and there is no
|
|
13
|
+
# deployment-wide fallback file to borrow one from. A shared fallback
|
|
14
|
+
# (a prior design: an agent without prompt_files silently inherited a
|
|
15
|
+
# wiring-level default) is exactly how a real deployment answered as the
|
|
16
|
+
# WRONG business: a `copilot` data agent provisioned without its own
|
|
17
|
+
# identity inherited the deployment's demo persona ("Bia, Pizzaria do
|
|
18
|
+
# Zé") byte for byte, confirmed live. An agent never wears another
|
|
19
|
+
# agent's identity — `@base`/`system_files` stay legitimate (the same
|
|
20
|
+
# generic content for every agent, never one agent's specific persona).
|
|
17
21
|
class Prompt < ContextProvider
|
|
18
|
-
|
|
22
|
+
# Engine-owned execution discipline, appended AFTER the agent's identity.
|
|
23
|
+
# The one behavior every reference harness bakes into its base prompt
|
|
24
|
+
# (OpenClaw's "Execution Bias") and this engine was missing: a weak tool
|
|
25
|
+
# result read as final. A constant — byte-identical every turn, so
|
|
26
|
+
# prompt_caching pays ONE write on the deploy that introduces it, never
|
|
27
|
+
# per turn. Opt-out per profile (`tool_persistence false`), the single
|
|
28
|
+
# default-ON profile flag: the proven-good behavior is the default, the
|
|
29
|
+
# exception is the thing an operator declares.
|
|
30
|
+
TOOL_PERSISTENCE = "## Tool discipline\n" \
|
|
31
|
+
"- Weak or empty tool result: try again with a different approach — rephrase the " \
|
|
32
|
+
"query, use a synonym or broader term, drop a secondary filter — before telling " \
|
|
33
|
+
"the user you found nothing. Do not narrate the retries. Then conclude.\n" \
|
|
34
|
+
"- Tool error: read the error, fix the arguments or try another path; never " \
|
|
35
|
+
"repeat the exact same call.\n" \
|
|
36
|
+
"- A URL in a tool result (e.g. a `url` field): quote it byte-for-byte. Never " \
|
|
37
|
+
"construct, guess, or rewrite the domain, host, or path."
|
|
38
|
+
|
|
39
|
+
def initialize(base: "", catalog: nil, agent_files: nil, system_files: nil)
|
|
19
40
|
@base = base
|
|
20
|
-
@files = Array(files)
|
|
21
41
|
@catalog = catalog
|
|
22
42
|
@agent_files = agent_files
|
|
23
43
|
@system_files = system_files
|
|
24
44
|
end
|
|
25
45
|
|
|
26
46
|
def required? = true
|
|
47
|
+
# identity (config/agent-file derived — already pinned).
|
|
48
|
+
def layer = :identity
|
|
27
49
|
|
|
28
50
|
def call(request)
|
|
29
|
-
fragments = []
|
|
30
51
|
identity = build_identity(request.profile)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
52
|
+
if identity.empty?
|
|
53
|
+
raise ContextError.new(
|
|
54
|
+
"agent '#{request.profile&.id}' has no identity of its own (no base_prompt, " \
|
|
55
|
+
"no prompt_files) — refusing to run rather than answer with no identity or " \
|
|
56
|
+
"another agent's",
|
|
57
|
+
provider: id
|
|
58
|
+
)
|
|
34
59
|
end
|
|
60
|
+
|
|
61
|
+
fragments = [ContextFragment.build(content: identity, placement: :system,
|
|
62
|
+
priority: Context::Priority::IDENTITY, source: id, pinned: true)]
|
|
35
63
|
fragments.concat(ref_fragments(request.profile))
|
|
36
64
|
fragments
|
|
37
65
|
end
|
|
@@ -42,10 +70,9 @@ module Insika
|
|
|
42
70
|
# A SINGLE fragment preserves the internal base->files order (sorting
|
|
43
71
|
# acts only BETWEEN fragments) and guarantees byte-for-byte parity.
|
|
44
72
|
#
|
|
45
|
-
# profile.prompt_files
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
# order. Without prompt_files, falls back to the wiring's @files.
|
|
73
|
+
# profile.prompt_files: each source resolves via AgentFileStore (per
|
|
74
|
+
# agent) OR File.read (on-disk path) — in that order. No other agent's
|
|
75
|
+
# files are ever read for this one; there is no wiring-level fallback.
|
|
49
76
|
def build_identity(profile)
|
|
50
77
|
parts = [@base]
|
|
51
78
|
parts.concat(system_parts) # global system files, for EVERY agent
|
|
@@ -56,13 +83,20 @@ module Insika
|
|
|
56
83
|
# NO identity at all, and a chatty model answered plausibly enough to hide
|
|
57
84
|
# it. Additive to prompt_files, not exclusive: an agent may carry both.
|
|
58
85
|
parts << profile&.base_prompt.to_s
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
86
|
+
Array(profile&.prompt_files).each { |src| parts << read_source(profile&.id, src.to_s) }
|
|
87
|
+
identity = parts.reject { |p| p.nil? || p.strip.empty? }.join("\n\n")
|
|
88
|
+
# Discipline rides an EXISTING identity, never substitutes one: an
|
|
89
|
+
# empty identity here is fatal (`call` raises), not silently patched
|
|
90
|
+
# with the engine's own boilerplate.
|
|
91
|
+
return identity if identity.empty? || !tool_persistence?(profile)
|
|
92
|
+
|
|
93
|
+
"#{identity}\n\n#{TOOL_PERSISTENCE}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# nil/absent/true = ON (the engine default); only an explicit `false`
|
|
97
|
+
# turns it off. Defensive respond_to?: a minimal profile stub reads ON.
|
|
98
|
+
def tool_persistence?(profile)
|
|
99
|
+
!(profile.respond_to?(:tool_persistence) && profile.tool_persistence == false)
|
|
66
100
|
end
|
|
67
101
|
|
|
68
102
|
# GLOBAL system files: apply to every agent,
|
|
@@ -24,7 +24,8 @@ module Insika
|
|
|
24
24
|
# suspends (approval) and resumes re-runs the context stage, and the
|
|
25
25
|
# re-record replaces the first one instead of duplicating it. (`turn` is
|
|
26
26
|
# 1-based PER TASK, so the task is part of the key.) Missing session_id ->
|
|
27
|
-
# no-op.
|
|
27
|
+
# no-op. -> the sanitized entry (parks it on TurnState for the
|
|
28
|
+
# stage-8 cache merge).
|
|
28
29
|
def record(session_id:, entry:)
|
|
29
30
|
sid = session_id.to_s
|
|
30
31
|
return if sid.empty?
|
|
@@ -33,6 +34,7 @@ module Insika
|
|
|
33
34
|
key = [e["task_id"], e["turn"]]
|
|
34
35
|
list = (@store.get(SCOPE, sid) || []).reject { |x| [x["task_id"], x["turn"]] == key } + [e]
|
|
35
36
|
@store.set(SCOPE, sid, list.last(MAX_PER_SESSION))
|
|
37
|
+
e
|
|
36
38
|
rescue StandardError
|
|
37
39
|
nil
|
|
38
40
|
end
|
|
@@ -63,6 +65,11 @@ module Insika
|
|
|
63
65
|
cat = { "tokens" => int(c[:tokens] || c["tokens"]),
|
|
64
66
|
"fragments" => int(c[:fragments] || c["fragments"]),
|
|
65
67
|
"pinned" => int(c[:pinned] || c["pinned"]) }
|
|
68
|
+
# which cache layer the category belongs to ("identity" |
|
|
69
|
+
# "volatile"). Absent for a category recorded before the contract (or
|
|
70
|
+
# one that never learned it) — the view guards on nil.
|
|
71
|
+
layer = c[:layer] || c["layer"]
|
|
72
|
+
cat["layer"] = layer.to_s if layer
|
|
66
73
|
# WHAT the category carried and WHY ({name, reason}) — still ids only, so
|
|
67
74
|
# the no-masking-needed contract above holds. Omitted when empty: most
|
|
68
75
|
# categories have nothing to name and an empty key is just noise.
|
|
@@ -71,8 +78,36 @@ module Insika
|
|
|
71
78
|
acc[name.to_s] = cat
|
|
72
79
|
end,
|
|
73
80
|
"tools" => { "count" => int(tools[:count] || tools["count"]),
|
|
74
|
-
"tokens" => int(tools[:tokens] || tools["tokens"]) }
|
|
81
|
+
"tokens" => int(tools[:tokens] || tools["tokens"]) },
|
|
82
|
+
"fingerprints" => fingerprints_of(e[:fingerprints] || e["fingerprints"]),
|
|
83
|
+
"cache" => cache_of(e[:cache] || e["cache"])
|
|
84
|
+
}.compact
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# { name => sha256-hex }; names stringified, non-strings
|
|
88
|
+
# dropped. Absent when the caller passed nothing (a trace recorded before
|
|
89
|
+
# this feature has no key and the view guards on nil).
|
|
90
|
+
def fingerprints_of(raw)
|
|
91
|
+
return nil unless raw.is_a?(Hash) && !raw.empty?
|
|
92
|
+
|
|
93
|
+
raw.each_with_object({}) do |(name, hex), acc|
|
|
94
|
+
acc[name.to_s] = hex.to_s if hex.is_a?(String)
|
|
95
|
+
end.then { |h| h.empty? ? nil : h }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# { hit_pct, cached_tokens, prompt_tokens, invalidation_reason }.
|
|
99
|
+
# Unknown keys dropped. Present only when the caller passed it.
|
|
100
|
+
def cache_of(raw)
|
|
101
|
+
return nil unless raw.is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
c = {
|
|
104
|
+
"hit_pct" => int_or_nil(raw[:hit_pct] || raw["hit_pct"]),
|
|
105
|
+
"cached_tokens" => int(raw[:cached_tokens] || raw["cached_tokens"]),
|
|
106
|
+
"prompt_tokens" => int(raw[:prompt_tokens] || raw["prompt_tokens"]),
|
|
107
|
+
"invalidation_reason" => raw[:invalidation_reason] || raw["invalidation_reason"]
|
|
75
108
|
}
|
|
109
|
+
c["invalidation_reason"] = c["invalidation_reason"].to_s unless c["invalidation_reason"].nil?
|
|
110
|
+
c
|
|
76
111
|
end
|
|
77
112
|
|
|
78
113
|
# Labels are {name, reason} in string keys (ContextFragment.label). A bare string
|
|
@@ -88,5 +123,6 @@ module Insika
|
|
|
88
123
|
end
|
|
89
124
|
|
|
90
125
|
def int(value) = Integer(value || 0)
|
|
126
|
+
def int_or_nil(value) = value.nil? ? nil : Integer(value)
|
|
91
127
|
end
|
|
92
128
|
end
|
data/lib/insika/cron.rb
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# A cron expression, parsed. The engine's documented subset:
|
|
5
|
+
#
|
|
6
|
+
# minute hour day-of-month month day-of-week (5 fields, whitespace-separated)
|
|
7
|
+
#
|
|
8
|
+
# Per field: `*`, a single value, a range (N-M), a step (`*/N`, `N-M/N`,
|
|
9
|
+
# `N/N`), or a comma list of those. `?` and `*` both mean "any". `L`, `W`,
|
|
10
|
+
# `#` and month/day NAMES are NOT — the engine refuses them loudly at
|
|
11
|
+
# creation, never silently dropping a cron that only some dates understand.
|
|
12
|
+
#
|
|
13
|
+
# Day-of-week: 0-7, 7 == 0 (Sunday). When BOTH day fields are restricted, a
|
|
14
|
+
# date matches on EITHER (standard cron OR semantics).
|
|
15
|
+
#
|
|
16
|
+
# `next_after(time)` returns the first occurrence STRICTLY after `time`,
|
|
17
|
+
# materialized in the schedule's tz (returned as a UTC Time), or nil when
|
|
18
|
+
# the expression can never fire within 400 years (`0 0 31 2 *`). The bound
|
|
19
|
+
# keeps a syntactic-but-unreachable expression from hanging a pass.
|
|
20
|
+
class Cron
|
|
21
|
+
MONTHS_31 = [1, 3, 5, 7, 8, 10, 12].freeze
|
|
22
|
+
|
|
23
|
+
# One field: the sorted accepted values + whether it was a bare "*"
|
|
24
|
+
# (the day-of-week OR rule needs to know which fields are restricted).
|
|
25
|
+
class Field
|
|
26
|
+
def initialize(expression, range, field:)
|
|
27
|
+
@field = field
|
|
28
|
+
@range = range
|
|
29
|
+
@star = expression == "*"
|
|
30
|
+
@values = expression.split(",").flat_map { |term| parse_term(term) }.uniq.sort
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def star? = @star
|
|
34
|
+
|
|
35
|
+
def matches?(value) = @values.include?(value)
|
|
36
|
+
|
|
37
|
+
# -> Integer | nil: the smallest accepted value >= start (nil when none).
|
|
38
|
+
def at_or_after(start) = @values.find { |v| v >= start }
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def parse_term(term)
|
|
43
|
+
head, step = term.include?("/") ? term.split("/", 2) : [term, nil]
|
|
44
|
+
step = parse_step(step) if step
|
|
45
|
+
base = parse_base(head)
|
|
46
|
+
base = base.each_with_index.filter_map { |v, i| v if (i % step).zero? } if step
|
|
47
|
+
base.each { |v| validate(v) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_step(raw)
|
|
51
|
+
n = Integer(raw)
|
|
52
|
+
raise Insika::ValidationError, "cron #{@field} step must be a positive integer, got #{raw.inspect}" unless n.positive?
|
|
53
|
+
|
|
54
|
+
n
|
|
55
|
+
rescue ArgumentError
|
|
56
|
+
raise Insika::ValidationError,
|
|
57
|
+
"cron #{@field} step must be a positive integer, got #{raw.inspect}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parse_base(head)
|
|
61
|
+
if head.empty? || head == "?" || head == "*"
|
|
62
|
+
@range.to_a
|
|
63
|
+
elsif head.include?("-")
|
|
64
|
+
lo, hi = head.split("-", 2).map { |s| value(s) }
|
|
65
|
+
raise Insika::ValidationError, "cron #{@field} range reversed: #{head.inspect}" if lo > hi
|
|
66
|
+
|
|
67
|
+
(lo..hi).to_a
|
|
68
|
+
else
|
|
69
|
+
[value(head)]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def value(raw)
|
|
74
|
+
v = Integer(raw)
|
|
75
|
+
v = 0 if @field == :dow && v == 7 # Sunday, the standard second spelling
|
|
76
|
+
v
|
|
77
|
+
rescue ArgumentError
|
|
78
|
+
raise Insika::ValidationError,
|
|
79
|
+
"cron #{@field} has an unparseable value: #{raw.inspect}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate(v)
|
|
83
|
+
return if @range.cover?(v)
|
|
84
|
+
|
|
85
|
+
raise Insika::ValidationError,
|
|
86
|
+
"cron #{@field} value #{v} out of range (#{@range.inspect})"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
attr_reader :expression
|
|
91
|
+
|
|
92
|
+
def initialize(expression)
|
|
93
|
+
@expression = expression.to_s.strip
|
|
94
|
+
fields = @expression.split(/\s+/)
|
|
95
|
+
if fields.size != 5
|
|
96
|
+
raise Insika::ValidationError,
|
|
97
|
+
"cron expression must have 5 fields (minute hour dom month dow), " \
|
|
98
|
+
"got #{fields.size}: #{@expression.inspect}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
minute, hour, dom, month, dow = fields
|
|
102
|
+
@minute = Field.new(minute, 0..59, field: :minute)
|
|
103
|
+
@hour = Field.new(hour, 0..23, field: :hour)
|
|
104
|
+
@dom = Field.new(dom, 1..31, field: :dom)
|
|
105
|
+
@month = Field.new(month, 1..12, field: :month)
|
|
106
|
+
@dow = Field.new(dow, 0..6, field: :dow)
|
|
107
|
+
freeze
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# -> Time (UTC) | nil: the first instant strictly after `time` whose
|
|
111
|
+
# wall-clock in `tz` matches. nil = the expression cannot fire within 400y.
|
|
112
|
+
def next_after(time, tz: "Etc/UTC")
|
|
113
|
+
Timezone.in_zone(tz, time) do
|
|
114
|
+
local = time.getlocal
|
|
115
|
+
cur = Cursor.new(local.year, local.month, local.day, local.hour, local.min)
|
|
116
|
+
advance_minute!(cur) # strictly after `time`
|
|
117
|
+
limit = cur.y + 400
|
|
118
|
+
while cur.y <= limit
|
|
119
|
+
if @month.matches?(cur.mo) && day_match?(cur)
|
|
120
|
+
if @hour.matches?(cur.h) && (mm = @minute.at_or_after(cur.mi))
|
|
121
|
+
return Time.local(cur.y, cur.mo, cur.d, cur.h, mm)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
nh = @hour.at_or_after(cur.h + 1)
|
|
125
|
+
if nh && (mm = @minute.at_or_after(0))
|
|
126
|
+
return Time.local(cur.y, cur.mo, cur.d, nh, mm)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
advance_day!(cur)
|
|
130
|
+
cur.h = 0
|
|
131
|
+
cur.mi = 0
|
|
132
|
+
end
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# A date matches when a restricted day-of-month OR day-of-week accepts it
|
|
140
|
+
# (standard cron); a bare "*" on one side drops that side.
|
|
141
|
+
def day_match?(cur)
|
|
142
|
+
dom = @dom.matches?(cur.d)
|
|
143
|
+
dow = @dow.matches?(Time.utc(cur.y, cur.mo, cur.d).wday)
|
|
144
|
+
if @dom.star? && @dow.star?
|
|
145
|
+
true
|
|
146
|
+
elsif @dom.star?
|
|
147
|
+
dow
|
|
148
|
+
elsif @dow.star?
|
|
149
|
+
dom
|
|
150
|
+
else
|
|
151
|
+
dom || dow
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
Cursor = Struct.new(:y, :mo, :d, :h, :mi)
|
|
156
|
+
|
|
157
|
+
def advance_minute!(cur)
|
|
158
|
+
cur.mi += 1
|
|
159
|
+
if cur.mi > 59
|
|
160
|
+
cur.mi = 0
|
|
161
|
+
cur.h += 1
|
|
162
|
+
advance_day!(cur) if cur.h > 23
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def advance_day!(cur)
|
|
167
|
+
cur.d += 1
|
|
168
|
+
return if cur.d <= days_in_month(cur.y, cur.mo)
|
|
169
|
+
|
|
170
|
+
cur.d = 1
|
|
171
|
+
cur.mo += 1
|
|
172
|
+
if cur.mo > 12
|
|
173
|
+
cur.mo = 1
|
|
174
|
+
cur.y += 1
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def days_in_month(y, mo)
|
|
179
|
+
return 29 if mo == 2 && leap?(y)
|
|
180
|
+
return 28 if mo == 2
|
|
181
|
+
|
|
182
|
+
MONTHS_31.include?(mo) ? 31 : 30
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def leap?(y)
|
|
186
|
+
y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|