insika 0.3.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 +180 -0
- data/README.md +45 -10
- data/bin/insika +684 -0
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +94 -403
- data/docs/API.md +5 -5
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +14 -14
- data/docs/CONTEXT.md +9 -7
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +71 -3
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +128 -3
- data/docs/FACTS.md +3 -3
- data/docs/HARVEST.md +5 -6
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +2 -2
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +15 -10
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +14 -9
- data/docs/RELEASING.md +4 -4
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +22 -6
- data/docs/SKILLS.md +11 -2
- data/docs/SOAK.md +2 -2
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +152 -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 +4 -4
- data/docs/improve.md +20 -0
- data/docs/index.md +8 -5
- 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 +73 -16
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/channel_delivery.rb +1 -1
- data/lib/insika/chat_builder.rb +22 -2
- data/lib/insika/commands/agent_payload.rb +2 -2
- data/lib/insika/commands/backfill_knowledge.rb +145 -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 +15 -3
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/context/priority.rb +2 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/prompt.rb +30 -24
- 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/doctor.rb +176 -8
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +60 -79
- data/lib/insika/dsl/server_boot.rb +23 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +103 -2
- data/lib/insika/env_schema.rb +16 -1
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +11 -0
- 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/executor.rb +231 -55
- data/lib/insika/followup_policy.rb +2 -25
- data/lib/insika/golden_store.rb +16 -1
- data/lib/insika/grounding/matcher.rb +1 -1
- 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 +115 -31
- data/lib/insika/message_origin.rb +1 -1
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/onboarding.rb +17 -1
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +2 -2
- data/lib/insika/profile_source.rb +8 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/retention.rb +36 -1
- 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/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/server/app.rb +105 -15
- data/lib/insika/server/rack_app.rb +5 -1
- data/lib/insika/server/responses.rb +1 -1
- data/lib/insika/skill_catalog.rb +12 -0
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/studio/app.rb +567 -45
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +21 -21
- data/lib/insika/studio/forms.rb +46 -5
- data/lib/insika/studio/nav_icons.rb +14 -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 +61 -820
- data/lib/insika/studio/views/agents.erb +70 -57
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/evals.erb +2 -2
- data/lib/insika/studio/views/facts.erb +1 -1
- data/lib/insika/studio/views/funnel.erb +1 -1
- data/lib/insika/studio/views/home.erb +106 -67
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +14 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/session.erb +231 -177
- data/lib/insika/studio/views/settings.erb +39 -1
- data/lib/insika/studio/views/skills.erb +1 -1
- data/lib/insika/studio/views/tools.erb +24 -9
- 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/tick.rb +24 -12
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tools/generate_image.rb +52 -7
- 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/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +15 -4
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +184 -12
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +57 -0
- metadata +105 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ---
|
|
4
|
+
# title: Browser Agent
|
|
5
|
+
# trail: MCP
|
|
6
|
+
# description: Live MCP tool-loop over stdio — navigates and summarizes a real webpage via Playwright's MCP server. Requires Node.js/npm and INSIKA_MCP_STDIO=1.
|
|
7
|
+
# capabilities: mcp, stdio
|
|
8
|
+
# env: INSIKA_MCP_STDIO
|
|
9
|
+
# requires: Node.js and npm (npx spawns the MCP server as a child process)
|
|
10
|
+
# ---
|
|
11
|
+
#
|
|
12
|
+
# browser-agent — a live MCP tool-loop over STDIO: the agent
|
|
13
|
+
# drives a real, sandboxed browser through Playwright's MCP server
|
|
14
|
+
# (@playwright/mcp, no API key). stdio is arbitrary command execution by
|
|
15
|
+
# config, so it needs the operator's explicit opt-in (INSIKA_MCP_STDIO=1)
|
|
16
|
+
# and Node.js/npm on the machine — the two things this template needs
|
|
17
|
+
# beyond the gem and a provider key. Swap MCP_COMMAND/MCP_ARGS for any other
|
|
18
|
+
# MCP server and nothing else in this file changes.
|
|
19
|
+
#
|
|
20
|
+
# INSIKA_MCP_STDIO=1 DEEPSEEK_API_KEY=sk-... ruby browser-agent/agent.rb "go to example.com and summarize the page"
|
|
21
|
+
require "insika"
|
|
22
|
+
require "shellwords"
|
|
23
|
+
|
|
24
|
+
browser = Insika.agent("browser-agent") do
|
|
25
|
+
model "deepseek-v4-flash"
|
|
26
|
+
provider :deepseek
|
|
27
|
+
|
|
28
|
+
instructions <<~PROMPT
|
|
29
|
+
You browse the web using the browser MCP tools. Navigate to the
|
|
30
|
+
requested page, then extract or summarize what was asked. Never invent
|
|
31
|
+
page content — always navigate and read it first.
|
|
32
|
+
PROMPT
|
|
33
|
+
|
|
34
|
+
mcp "browser", transport: :stdio,
|
|
35
|
+
command: ENV.fetch("MCP_COMMAND", "npx"),
|
|
36
|
+
args: Shellwords.split(ENV.fetch("MCP_ARGS", "-y @playwright/mcp@latest"))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if __FILE__ == $PROGRAM_NAME
|
|
40
|
+
if ARGV.delete("--serve")
|
|
41
|
+
browser.serve
|
|
42
|
+
else
|
|
43
|
+
message = ARGV.join(" ")
|
|
44
|
+
message = "Go to https://example.com and summarize the page in two sentences." if message.empty?
|
|
45
|
+
puts browser.reply(message)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
browser
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# daily-digest
|
|
2
|
+
|
|
3
|
+
**Always-on trail.** The report pipeline in one file: a recurring
|
|
4
|
+
`schedule` (the engine's own tick fires it), a `save_artifact`
|
|
5
|
+
tool (the report destination), and a store-free skill describing
|
|
6
|
+
*how* to build the report. No database, no store content — the day's
|
|
7
|
+
numbers are fake, inline text.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
DEEPSEEK_API_KEY=sk-... ruby daily-digest/agent.rb
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Expected output: a reply ending in `Report: /studio/artifacts/<id>`.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
DEEPSEEK_API_KEY=sk-... ruby daily-digest/agent.rb --serve
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then open `/studio`, log in with the printed token, and either wait for the
|
|
20
|
+
schedule (22:00 America/Sao_Paulo) or send a message to the "reporter" agent
|
|
21
|
+
in the Playground — the artifact lands on the Artifacts tab either way.
|
|
22
|
+
|
|
23
|
+
## What's real here
|
|
24
|
+
|
|
25
|
+
- The **schedule** is a live engine feature: the tick actually fires this
|
|
26
|
+
turn at the cron time, in the same process, no external cron needed.
|
|
27
|
+
- The **artifact** is a real signed/authenticated URL the report is saved
|
|
28
|
+
to — open it and the HTML renders with its own strict CSP (`default-src
|
|
29
|
+
'none'`), so the inline SVG chart has to be self-contained by
|
|
30
|
+
construction, not by convention.
|
|
31
|
+
- The **numbers** are not. Swap the literal string in `agent.rb` for a
|
|
32
|
+
`data_tool` against your own sales API and nothing else changes.
|
|
33
|
+
|
|
34
|
+
## Edit it
|
|
35
|
+
|
|
36
|
+
The skill's instructions are the actual report spec — change the palette,
|
|
37
|
+
add a second table, or add another chart. The schedule's `cron`/`tz` are
|
|
38
|
+
plain arguments.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ---
|
|
4
|
+
# title: Daily Digest
|
|
5
|
+
# trail: Always-on
|
|
6
|
+
# description: A recurring schedule plus save_artifact build and publish a self-contained HTML report — no store, fake in-memory numbers only.
|
|
7
|
+
# capabilities: schedule, save_artifact, skill
|
|
8
|
+
# ---
|
|
9
|
+
#
|
|
10
|
+
# daily-digest — the report pipeline in one file: a recurring schedule (the
|
|
11
|
+
# engine's own tick fires it), a daily-digest skill (the inline-SVG
|
|
12
|
+
# pattern), the save_artifact tool (the report destination) and the
|
|
13
|
+
# per-agent allowlist that gates it. No store content — fake, in-memory
|
|
14
|
+
# "sales" numbers only.
|
|
15
|
+
#
|
|
16
|
+
# DEEPSEEK_API_KEY=sk-... ruby daily-digest/agent.rb
|
|
17
|
+
# DEEPSEEK_API_KEY=sk-... ruby daily-digest/agent.rb --serve
|
|
18
|
+
# # --serve: open /studio, log in with the printed token, then send a
|
|
19
|
+
# # message to the "reporter" agent in the Playground — the artifact
|
|
20
|
+
# # lands on the Artifacts tab. The schedule fires the same run at
|
|
21
|
+
# # 22:00 America/Sao_Paulo on its own.
|
|
22
|
+
require "insika"
|
|
23
|
+
|
|
24
|
+
todays_sales = "Coffee 128 units ($512), Tea 64 units ($192), Pastries 40 units ($160)."
|
|
25
|
+
|
|
26
|
+
reporter = Insika.agent("reporter") do
|
|
27
|
+
model "deepseek-v4-flash"
|
|
28
|
+
provider :deepseek
|
|
29
|
+
|
|
30
|
+
instructions <<~PROMPT
|
|
31
|
+
You produce the daily sales digest from the numbers given to you in the
|
|
32
|
+
message. Follow the daily-digest skill exactly, then save the finished
|
|
33
|
+
page with save_artifact (HTML with inline SVG). End your reply with the
|
|
34
|
+
artifact url, on its own line, prefixed with "Report: ".
|
|
35
|
+
PROMPT
|
|
36
|
+
|
|
37
|
+
# The per-agent allowlist IS the switch for save_artifact — without this
|
|
38
|
+
# line the model never even sees the tool.
|
|
39
|
+
tools %w[save_artifact]
|
|
40
|
+
|
|
41
|
+
# A recurring turn — the engine's tick fires it. session_mode:
|
|
42
|
+
# "new" = a fresh session per run (the report shape); the overrides raise
|
|
43
|
+
# the chat-time ceilings a real report needs.
|
|
44
|
+
schedule "daily_report", cron: "0 22 * * *", tz: "America/Sao_Paulo",
|
|
45
|
+
message: "Run the daily report now. Today's sales: #{todays_sales}",
|
|
46
|
+
session_mode: "new",
|
|
47
|
+
overrides: { turn_timeout: 600, max_tool_calls: 120 }
|
|
48
|
+
|
|
49
|
+
# The generic, store-free skill: how a report is BUILT — the inline-SVG
|
|
50
|
+
# pattern (palette, table, pure-SVG bars, light/dark). No store ids, no
|
|
51
|
+
# queries — that half belongs in a merchant's pack, not here.
|
|
52
|
+
skill "daily-digest",
|
|
53
|
+
description: "How to build the daily sales digest as a self-contained HTML report",
|
|
54
|
+
instructions: <<~MD
|
|
55
|
+
Build the digest as ONE self-contained HTML page:
|
|
56
|
+
- a <style> block with a light palette (e.g. #f8fafc bg, #0f172a text,
|
|
57
|
+
#6366f1 accent), plus a prefers-color-scheme: dark override;
|
|
58
|
+
- one <table> of the numbers (th/tbody, right-aligned amounts);
|
|
59
|
+
- one inline SVG bar chart (no <script>, no <img>, no <iframe>,
|
|
60
|
+
no external fonts or fetches — the report is served with
|
|
61
|
+
default-src 'none', so nothing external can load);
|
|
62
|
+
- a title and the run date.
|
|
63
|
+
Keep it readable on a phone. The report is the deliverable; the
|
|
64
|
+
channel message is just the link to it.
|
|
65
|
+
MD
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if __FILE__ == $PROGRAM_NAME
|
|
69
|
+
if ARGV.delete("--serve")
|
|
70
|
+
reporter.serve
|
|
71
|
+
else
|
|
72
|
+
puts reporter.reply("Run the daily report now. Today's sales: #{todays_sales}")
|
|
73
|
+
puts "\nThen open the returned /studio/artifacts/<id> page (or --serve and look at the Artifacts tab)."
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
reporter
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# repo-explorer
|
|
2
|
+
|
|
3
|
+
**MCP trail (http).** A live MCP tool-loop over Streamable HTTP:
|
|
4
|
+
the agent calls a real, running MCP server's tools — not a snapshot, not a
|
|
5
|
+
hand-rolled HTTP wrapper. The default target is
|
|
6
|
+
[DeepWiki's public MCP server](https://mcp.deepwiki.com/mcp), which needs
|
|
7
|
+
no API key for public repos.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
DEEPSEEK_API_KEY=sk-... ruby repo-explorer/agent.rb "how does rails/rails route a request?"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
This is not a DeepWiki showcase — it's exactly how you plug **any** MCP
|
|
14
|
+
server into an agent:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
MCP_URL=https://your-mcp-server/mcp DEEPSEEK_API_KEY=sk-... ruby repo-explorer/agent.rb "..."
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Point `MCP_URL` at any Streamable HTTP or SSE MCP server and nothing else
|
|
21
|
+
in `agent.rb` changes — swap the URL, rewrite the instructions for the new
|
|
22
|
+
server's tools, done.
|
|
23
|
+
|
|
24
|
+
## Under the hood
|
|
25
|
+
|
|
26
|
+
`mcp "repo-docs", transport: :http, url: …` declares the instance; the
|
|
27
|
+
engine connects live, discovers its tools (`read_wiki_structure`,
|
|
28
|
+
`read_wiki_contents`, `ask_question`), and wires them straight into the
|
|
29
|
+
agent's tool-loop with group `mcp:repo-docs`. Declaring an `mcp` inside an
|
|
30
|
+
agent's block auto-grants that agent access to the group — see
|
|
31
|
+
`lib/insika/dsl.rb`'s `mcp` method if you're curious why that matters.
|
|
32
|
+
|
|
33
|
+
A public HTTPS target needs no extra configuration (same egress guard as
|
|
34
|
+
any data-tool). A target on a private network needs the same
|
|
35
|
+
`INSIKA_EGRESS_ALLOW_PRIVATE`/`INSIKA_EGRESS_HOSTS` env vars a data-tool
|
|
36
|
+
would.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ---
|
|
4
|
+
# title: Repo Explorer
|
|
5
|
+
# trail: MCP
|
|
6
|
+
# description: Live MCP tool-loop over http — answers questions about any public GitHub repo via a keyless public MCP server. Point MCP_URL at any other MCP server instead.
|
|
7
|
+
# capabilities: mcp, http
|
|
8
|
+
# ---
|
|
9
|
+
#
|
|
10
|
+
# repo-explorer — a live MCP tool-loop over HTTP: the agent calls
|
|
11
|
+
# a real MCP server's tools (read_wiki_structure, read_wiki_contents,
|
|
12
|
+
# ask_question) to answer questions about a public GitHub repo. The default
|
|
13
|
+
# target is DeepWiki's public, keyless MCP server — swap MCP_URL for any
|
|
14
|
+
# other MCP server and nothing else in this file changes.
|
|
15
|
+
#
|
|
16
|
+
# DEEPSEEK_API_KEY=sk-... ruby repo-explorer/agent.rb "how does rails/rails route a request?"
|
|
17
|
+
# MCP_URL=https://your-mcp-server/mcp DEEPSEEK_API_KEY=sk-... ruby repo-explorer/agent.rb "..."
|
|
18
|
+
require "insika"
|
|
19
|
+
|
|
20
|
+
repo = Insika.agent("repo-explorer") do
|
|
21
|
+
model "deepseek-v4-flash"
|
|
22
|
+
provider :deepseek
|
|
23
|
+
|
|
24
|
+
instructions <<~PROMPT
|
|
25
|
+
You answer questions about public GitHub repositories using the
|
|
26
|
+
repo-docs MCP tools (read_wiki_structure, read_wiki_contents,
|
|
27
|
+
ask_question). Repos are named "owner/repo" (e.g. "rails/rails"). Never
|
|
28
|
+
answer from your own training data when a tool can check — call
|
|
29
|
+
ask_question first.
|
|
30
|
+
PROMPT
|
|
31
|
+
|
|
32
|
+
mcp "repo-docs", transport: :http, url: ENV.fetch("MCP_URL", "https://mcp.deepwiki.com/mcp")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if __FILE__ == $PROGRAM_NAME
|
|
36
|
+
if ARGV.delete("--serve")
|
|
37
|
+
repo.serve
|
|
38
|
+
else
|
|
39
|
+
message = ARGV.join(" ")
|
|
40
|
+
message = "In the rails/rails repo, how does routing work? One paragraph." if message.empty?
|
|
41
|
+
puts repo.reply(message)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
repo
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# research-analyst
|
|
2
|
+
|
|
3
|
+
**Advanced trail.** Four agents in one `Insika.system`: three specialists
|
|
4
|
+
(market, technical, risk) and a lead ("analyst") that has no expertise of
|
|
5
|
+
its own and must delegate. The MODEL decides to fan out — nothing in Ruby
|
|
6
|
+
orchestrates the parallel calls.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
DEEPSEEK_API_KEY=sk-... ruby research-analyst/agent.rb "a subscription box for specialty coffee"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Under the hood: the lead calls `spawn_subagents` once with all three
|
|
13
|
+
specialist ids, they run **in parallel** (each in an isolated context — a
|
|
14
|
+
child never sees the parent's conversation), and the lead synthesizes their
|
|
15
|
+
three answers into one recommendation.
|
|
16
|
+
|
|
17
|
+
Nothing forces the model to delegate — that's the trade of a model-driven
|
|
18
|
+
pattern over a hand-coded workflow. If it answers alone instead, the fix is
|
|
19
|
+
the lead's prompt, not the code: it needs to be told, plainly, that it has
|
|
20
|
+
no expertise of its own.
|
|
21
|
+
|
|
22
|
+
## Edit it
|
|
23
|
+
|
|
24
|
+
Add a fourth specialist (`agent("competitors") { … }`, then add it to
|
|
25
|
+
`subagents`), or turn any specialist into a `Insika.agent` with its own
|
|
26
|
+
data-tools — a subagent is an ordinary agent, capability included.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ---
|
|
4
|
+
# title: Research Analyst
|
|
5
|
+
# trail: Advanced
|
|
6
|
+
# description: Insika.system fan-out — three specialist subagents research different angles of a topic in parallel, the lead delegates and synthesizes.
|
|
7
|
+
# capabilities: subagents, delegation, system
|
|
8
|
+
# ---
|
|
9
|
+
#
|
|
10
|
+
# research-analyst — a lead agent with no expertise of its own: it must
|
|
11
|
+
# delegate. spawn_subagents fans out to three specialists on separate angles
|
|
12
|
+
# of a business idea, IN PARALLEL (each in its own isolated context), then
|
|
13
|
+
# the lead synthesizes one recommendation.
|
|
14
|
+
#
|
|
15
|
+
# DEEPSEEK_API_KEY=sk-... ruby research-analyst/agent.rb "a subscription box for specialty coffee"
|
|
16
|
+
# DEEPSEEK_API_KEY=sk-... ruby research-analyst/agent.rb --serve
|
|
17
|
+
require "insika"
|
|
18
|
+
|
|
19
|
+
team = Insika.system do
|
|
20
|
+
provider :deepseek
|
|
21
|
+
|
|
22
|
+
agent("market") do
|
|
23
|
+
model "deepseek-v4-flash"
|
|
24
|
+
instructions "Research the MARKET angle of a business idea: audience, demand, competitors. Three sentences."
|
|
25
|
+
end
|
|
26
|
+
agent("technical") do
|
|
27
|
+
model "deepseek-v4-flash"
|
|
28
|
+
instructions "Research the TECHNICAL/OPERATIONAL angle of a business idea: what it takes to build and run it. Three sentences."
|
|
29
|
+
end
|
|
30
|
+
agent("risk") do
|
|
31
|
+
model "deepseek-v4-flash"
|
|
32
|
+
instructions "Research the RISK angle of a business idea: what could make it fail. Three sentences."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
agent "analyst" do
|
|
36
|
+
model "deepseek-v4-flash"
|
|
37
|
+
instructions <<~PROMPT
|
|
38
|
+
You are a research LEAD with no expertise of your own — never answer
|
|
39
|
+
from your own knowledge. Given a business idea, call spawn_subagents
|
|
40
|
+
once with all three specialists (market, technical, risk), then
|
|
41
|
+
synthesize their findings into one short recommendation: go, no-go, or
|
|
42
|
+
go-with-changes, and why.
|
|
43
|
+
PROMPT
|
|
44
|
+
subagents "market", "technical", "risk"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if __FILE__ == $PROGRAM_NAME
|
|
49
|
+
if ARGV.delete("--serve")
|
|
50
|
+
team.serve
|
|
51
|
+
else
|
|
52
|
+
topic = ARGV.join(" ")
|
|
53
|
+
topic = "a subscription box for specialty coffee" if topic.empty?
|
|
54
|
+
puts team.reply("analyst", "Research this idea: #{topic}")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
team
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# review-panel
|
|
2
|
+
|
|
3
|
+
**Teams trail.** The `Insika.system` snippet from the gem's main examples
|
|
4
|
+
README, promoted to a runnable template: a "reviewer" lead with no
|
|
5
|
+
expertise of its own delegates to two specialists — security and
|
|
6
|
+
performance — IN PARALLEL, then synthesizes one prioritized fix.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
DEEPSEEK_API_KEY=sk-... ruby review-panel/agent.rb
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`panel.reply("reviewer", …)` — the target agent is always explicit; with
|
|
13
|
+
several agents in one system, inferring which one should answer would be a
|
|
14
|
+
guess, and a wrong guess is a silently wrong conversation.
|
|
15
|
+
|
|
16
|
+
## Edit it
|
|
17
|
+
|
|
18
|
+
Add a third specialist (a `style` reviewer, say), list it in `subagents`,
|
|
19
|
+
and the lead's synthesis prompt already generalizes — it doesn't name the
|
|
20
|
+
specialists, just says "both".
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ---
|
|
4
|
+
# title: Review Panel
|
|
5
|
+
# trail: Teams
|
|
6
|
+
# description: Two specialists reviewed in parallel by a synthesizing lead, explicit target agent (Insika.system + subagents).
|
|
7
|
+
# capabilities: subagents, delegation, system
|
|
8
|
+
# ---
|
|
9
|
+
#
|
|
10
|
+
# review-panel — promotes the examples/README.md snippet to a runnable
|
|
11
|
+
# template: a "reviewer" lead that has no expertise of its own delegates to
|
|
12
|
+
# two specialists (security, performance) IN PARALLEL, then synthesizes.
|
|
13
|
+
#
|
|
14
|
+
# DEEPSEEK_API_KEY=sk-... ruby review-panel/agent.rb
|
|
15
|
+
# DEEPSEEK_API_KEY=sk-... ruby review-panel/agent.rb --serve
|
|
16
|
+
require "insika"
|
|
17
|
+
|
|
18
|
+
panel = Insika.system do
|
|
19
|
+
provider :deepseek
|
|
20
|
+
|
|
21
|
+
agent("security") { model "deepseek-v4-flash"; instructions "Review code for SECURITY issues. Two sentences." }
|
|
22
|
+
agent("performance") { model "deepseek-v4-flash"; instructions "Review code for PERFORMANCE issues. Two sentences." }
|
|
23
|
+
|
|
24
|
+
agent "reviewer" do
|
|
25
|
+
model "deepseek-v4-flash"
|
|
26
|
+
instructions <<~PROMPT
|
|
27
|
+
You are a review LEAD with no reviewing expertise of your own — never
|
|
28
|
+
review from your own knowledge. Call spawn_subagents once with both
|
|
29
|
+
specialists (security, performance), then synthesize their findings
|
|
30
|
+
into the single highest-priority fix.
|
|
31
|
+
PROMPT
|
|
32
|
+
subagents "security", "performance"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if __FILE__ == $PROGRAM_NAME
|
|
37
|
+
code = <<~RUBY
|
|
38
|
+
def find_user(name)
|
|
39
|
+
User.where("name = '\#{name}'").to_a.select { |u| u.active }
|
|
40
|
+
end
|
|
41
|
+
RUBY
|
|
42
|
+
|
|
43
|
+
if ARGV.delete("--serve")
|
|
44
|
+
panel.serve
|
|
45
|
+
else
|
|
46
|
+
puts panel.reply("reviewer", "Review this code:\n#{code}")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
panel
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# travel-planner
|
|
2
|
+
|
|
3
|
+
**Starter trail.** A trip-planning assistant built entirely from **declarative
|
|
4
|
+
data-tools** — no Ruby tool class, no rebuild. Three tools, three public
|
|
5
|
+
HTTPS APIs, zero API keys beyond your LLM provider's:
|
|
6
|
+
|
|
7
|
+
- `geocode_city` — Open-Meteo's geocoding API (city name → coordinates)
|
|
8
|
+
- `get_weather` — Open-Meteo's forecast API (coordinates → today's conditions)
|
|
9
|
+
- `convert_currency` — Frankfurter's reference exchange rates
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
DEEPSEEK_API_KEY=sk-... ruby travel-planner/agent.rb "3 days in Lisbon, budget 200 USD"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The model chains the first two tools itself (geocode, then weather) and
|
|
16
|
+
calls the third when a budget is mentioned — nothing here tells it the
|
|
17
|
+
order, the instructions just describe what each tool is for.
|
|
18
|
+
|
|
19
|
+
## Egress guard (SSRF protection)
|
|
20
|
+
|
|
21
|
+
Data-tools make **server-side** HTTP calls, so the engine ships an egress
|
|
22
|
+
guard that is strict by default: public HTTPS only. This template works
|
|
23
|
+
with zero configuration because all three endpoints are public HTTPS — a
|
|
24
|
+
tool pointed at `http://…`, `localhost`, or a private IP would be blocked
|
|
25
|
+
instead (`{ error: "destination blocked: …" }` back to the model, a clean
|
|
26
|
+
tool error, never a crash). Opting a private/internal target in is a
|
|
27
|
+
deployment env var (`INSIKA_EGRESS_ALLOW_HTTP`/`_PRIVATE`/`_HOSTS`), never a
|
|
28
|
+
DSL setting — see `docs/TOOLS.md`'s "MCP servers" / egress sections in the
|
|
29
|
+
installed gem's docs for the full contract.
|
|
30
|
+
|
|
31
|
+
## Edit it
|
|
32
|
+
|
|
33
|
+
Open `agent.rb` — it's the same file `insika new` copied and the same one
|
|
34
|
+
this README describes. Add a fourth data-tool, change the model, or point
|
|
35
|
+
an existing one at a different provider; nothing else needs to change.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ---
|
|
4
|
+
# title: Travel Planner
|
|
5
|
+
# trail: Starter
|
|
6
|
+
# description: Weather + currency data-tools against keyless public APIs (Open-Meteo, Frankfurter) — the egress guard does its job with zero configuration.
|
|
7
|
+
# capabilities: data-tool, egress-guard
|
|
8
|
+
# ---
|
|
9
|
+
#
|
|
10
|
+
# travel-planner — plans a trip: geocodes the destination, checks today's
|
|
11
|
+
# weather, and converts a budget to the local currency. Three declarative
|
|
12
|
+
# data-tools, no Ruby tool class, no API key beyond the LLM provider's.
|
|
13
|
+
#
|
|
14
|
+
# DEEPSEEK_API_KEY=sk-... ruby travel-planner/agent.rb "3 days in Lisbon, budget 200 USD"
|
|
15
|
+
# DEEPSEEK_API_KEY=sk-... ruby travel-planner/agent.rb --serve
|
|
16
|
+
require "insika"
|
|
17
|
+
|
|
18
|
+
travel = Insika.agent("travel-planner") do
|
|
19
|
+
model "deepseek-v4-flash"
|
|
20
|
+
provider :deepseek
|
|
21
|
+
|
|
22
|
+
instructions <<~PROMPT
|
|
23
|
+
You are a travel-planning assistant. Given a destination and, optionally,
|
|
24
|
+
a budget amount + currency:
|
|
25
|
+
1. geocode_city to find its coordinates — never guess them.
|
|
26
|
+
2. get_weather for those coordinates and summarize today's conditions.
|
|
27
|
+
3. If a budget was given, convert_currency to the destination's local
|
|
28
|
+
currency and report the converted amount.
|
|
29
|
+
Never invent coordinates, weather or exchange rates — always call the tools.
|
|
30
|
+
PROMPT
|
|
31
|
+
|
|
32
|
+
data_tool(
|
|
33
|
+
"name" => "geocode_city",
|
|
34
|
+
"description" => "Latitude/longitude for a city name (Open-Meteo geocoding).",
|
|
35
|
+
"parameters" => {
|
|
36
|
+
"type" => "object",
|
|
37
|
+
"properties" => { "city" => { "type" => "string", "description" => "city name, e.g. Lisbon" } },
|
|
38
|
+
"required" => ["city"]
|
|
39
|
+
},
|
|
40
|
+
"request" => { "method" => "GET", "url" => "https://geocoding-api.open-meteo.com/v1/search?name={{city}}&count=1" },
|
|
41
|
+
"response" => { "extract" => "body_raw" }
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
data_tool(
|
|
45
|
+
"name" => "get_weather",
|
|
46
|
+
"description" => "Current weather for a latitude/longitude (Open-Meteo).",
|
|
47
|
+
"parameters" => {
|
|
48
|
+
"type" => "object",
|
|
49
|
+
"properties" => {
|
|
50
|
+
"latitude" => { "type" => "number", "description" => "from geocode_city" },
|
|
51
|
+
"longitude" => { "type" => "number", "description" => "from geocode_city" }
|
|
52
|
+
},
|
|
53
|
+
"required" => %w[latitude longitude]
|
|
54
|
+
},
|
|
55
|
+
"request" => { "method" => "GET", "url" => "https://api.open-meteo.com/v1/forecast?latitude={{latitude}}&longitude={{longitude}}¤t_weather=true" },
|
|
56
|
+
"response" => { "extract" => "body_raw" }
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Author the FINAL url — the HTTP client does not follow redirects.
|
|
60
|
+
# api.frankfurter.app now redirects to api.frankfurter.dev.
|
|
61
|
+
data_tool(
|
|
62
|
+
"name" => "convert_currency",
|
|
63
|
+
"description" => "Latest reference exchange rate between two currencies.",
|
|
64
|
+
"parameters" => {
|
|
65
|
+
"type" => "object",
|
|
66
|
+
"properties" => {
|
|
67
|
+
"from" => { "type" => "string", "description" => "source currency code, e.g. USD" },
|
|
68
|
+
"to" => { "type" => "string", "description" => "target currency code, e.g. BRL" }
|
|
69
|
+
},
|
|
70
|
+
"required" => %w[from to]
|
|
71
|
+
},
|
|
72
|
+
"request" => { "method" => "GET", "url" => "https://api.frankfurter.dev/v1/latest?from={{from}}&to={{to}}" },
|
|
73
|
+
"response" => { "extract" => "body_raw" }
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if __FILE__ == $PROGRAM_NAME
|
|
78
|
+
if ARGV.delete("--serve")
|
|
79
|
+
travel.serve
|
|
80
|
+
else
|
|
81
|
+
message = ARGV.join(" ")
|
|
82
|
+
message = "I'm spending 3 days in Lisbon with a budget of 200 USD. What should I pack, and how much is that in EUR?" if message.empty?
|
|
83
|
+
puts travel.reply(message)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
travel
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Template gallery: example agents shipped INSIDE the gem
|
|
7
|
+
# (`lib/insika/templates/<name>/{agent.rb,README.md}`), one DSL file per
|
|
8
|
+
# template that is BOTH doors — `insika new <name>` copies it for the user
|
|
9
|
+
# to run and edit, and this module `evaluate`s the same file to hand its
|
|
10
|
+
# pack(s) to the Studio's "New from template" gallery. No parallel pack
|
|
11
|
+
# format to drift.
|
|
12
|
+
#
|
|
13
|
+
# A template's `agent.rb` guards its CLI demo footer with
|
|
14
|
+
# `if __FILE__ == $PROGRAM_NAME` (false when this module evaluates it) and
|
|
15
|
+
# ends with the bare `Insika.agent`/`Insika.system` result as its LAST
|
|
16
|
+
# expression, so `evaluate` gets it back as the string-eval's return value
|
|
17
|
+
# — no registration call, no second source of truth.
|
|
18
|
+
module Templates
|
|
19
|
+
ROOT = File.expand_path("templates", __dir__)
|
|
20
|
+
|
|
21
|
+
Entry = Data.define(:name, :title, :trail, :description, :capabilities, :studio, :env, :requires) do
|
|
22
|
+
def studio? = studio
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# -> [String] template dirs that have an agent.rb, lexicographic.
|
|
28
|
+
def names
|
|
29
|
+
return [] unless Dir.exist?(ROOT)
|
|
30
|
+
|
|
31
|
+
Dir.children(ROOT).select { |n| File.file?(agent_path(n)) }.sort
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# -> [Entry] every template, parsed metadata only (no evaluation — cheap,
|
|
35
|
+
# safe to call on every render of the Studio gallery).
|
|
36
|
+
def all = names.map { |n| read(n) }
|
|
37
|
+
|
|
38
|
+
# -> Entry for one template. Raises NotFoundError for an unknown name —
|
|
39
|
+
# same discipline as a missing agent/MCP instance.
|
|
40
|
+
def read(name)
|
|
41
|
+
path = agent_path(name)
|
|
42
|
+
raise Insika::NotFoundError, "template '#{name}' not found" unless File.file?(path)
|
|
43
|
+
|
|
44
|
+
meta = frontmatter(File.read(path))
|
|
45
|
+
Entry.new(
|
|
46
|
+
name: name.to_s, title: presence(meta["title"]) || name.to_s, trail: presence(meta["trail"]),
|
|
47
|
+
description: meta["description"].to_s,
|
|
48
|
+
capabilities: split_list(meta["capabilities"]),
|
|
49
|
+
studio: meta.fetch("studio", true) != false,
|
|
50
|
+
env: split_list(meta["env"]), requires: presence(meta["requires"])
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Evaluates the template's agent.rb in an ISOLATED scope (a fresh Object's
|
|
55
|
+
# instance_eval) and returns whatever its last expression is — the built
|
|
56
|
+
# `Insika::DSL::Definition` or `Insika::DSL::System`. $PROGRAM_NAME here is
|
|
57
|
+
# whatever process called this (rspec, the CLI, the Studio server), never
|
|
58
|
+
# this file's path, so the template's own `if __FILE__ == $PROGRAM_NAME`
|
|
59
|
+
# demo footer never runs: no network call, no ARGV parsing, no puts.
|
|
60
|
+
#
|
|
61
|
+
# The fresh-Object receiver keeps a template's local variables and `def`s
|
|
62
|
+
# from leaking into the next one evaluated in the same process; a
|
|
63
|
+
# top-level CONSTANT would still leak (Ruby scopes constant assignment
|
|
64
|
+
# lexically, not by `self`) — wave-1 templates simply don't declare any
|
|
65
|
+
# (the conformance spec, would catch a future one that did).
|
|
66
|
+
def evaluate(name)
|
|
67
|
+
path = agent_path(name)
|
|
68
|
+
raise Insika::NotFoundError, "template '#{name}' not found" unless File.file?(path)
|
|
69
|
+
|
|
70
|
+
Object.new.instance_eval(File.read(path), path)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# -> [Pack] one per agent, regardless of whether the template is a single
|
|
74
|
+
# `Insika.agent` (Definition#to_pack) or a system (System#to_packs).
|
|
75
|
+
def packs_for(name)
|
|
76
|
+
built = evaluate(name)
|
|
77
|
+
built.respond_to?(:to_packs) ? built.to_packs : [built.to_pack]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def agent_path(name) = File.join(ROOT, name.to_s, "agent.rb")
|
|
81
|
+
def readme_path(name) = File.join(ROOT, name.to_s, "README.md")
|
|
82
|
+
|
|
83
|
+
# A `# ---` … `# ---` comment block at the very top of the file, YAML
|
|
84
|
+
# inside (each line stripped of its leading `# `). Not real Ruby
|
|
85
|
+
# frontmatter (there's no such thing) — a convention this module alone
|
|
86
|
+
# parses, so the metadata lives in the one file without needing a
|
|
87
|
+
# side-channel manifest.
|
|
88
|
+
def frontmatter(source)
|
|
89
|
+
lines = source.lines
|
|
90
|
+
# Every template starts with the same magic comment every other .rb
|
|
91
|
+
# file in the gem does — skip it (and any blank line) before looking
|
|
92
|
+
# for the block, so templates don't have to break that convention.
|
|
93
|
+
lines = lines.drop(1) while lines.first && (lines.first.strip.empty? || lines.first.strip == "# frozen_string_literal: true")
|
|
94
|
+
return {} unless lines.first&.strip == "# ---"
|
|
95
|
+
|
|
96
|
+
body = lines.drop(1)
|
|
97
|
+
.take_while { |l| l.strip != "# ---" }
|
|
98
|
+
.map { |l| l.sub(/\A#\s?/, "") }
|
|
99
|
+
.join
|
|
100
|
+
YAML.safe_load(body) || {}
|
|
101
|
+
end
|
|
102
|
+
private_class_method :frontmatter
|
|
103
|
+
|
|
104
|
+
def split_list(value)
|
|
105
|
+
value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
106
|
+
end
|
|
107
|
+
private_class_method :split_list
|
|
108
|
+
|
|
109
|
+
def presence(str) = Insika::Coercion.presence(str)
|
|
110
|
+
private_class_method :presence
|
|
111
|
+
end
|
|
112
|
+
end
|