robot_lab 0.3.0 → 0.3.1
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 +12 -0
- data/README.md +1 -1
- data/lib/robot_lab/mcp/transports/streamable_http.rb +1 -1
- data/lib/robot_lab/version.rb +1 -1
- metadata +3 -253
- data/.architecture/AGENTS.md +0 -32
- data/.architecture/config.yml +0 -8
- data/.architecture/members.yml +0 -60
- data/.architecture/reviews/feature-free-will.md +0 -490
- data/.architecture/reviews/overall-codebase.md +0 -427
- data/.claude/settings.local.json +0 -57
- data/.codex/config.toml +0 -2
- data/.envrc +0 -6
- data/.github/workflows/deploy-github-pages.yml +0 -52
- data/.irbrc +0 -6
- data/.loki +0 -65
- data/.quality/reek_baseline.txt +0 -43
- data/.rubocop.yml +0 -10
- data/Archspec.rb +0 -44
- data/CLAUDE.md +0 -141
- data/COMMITS.md +0 -196
- data/Rakefile +0 -193
- data/_typos.toml +0 -21
- data/agent2agent_review.md +0 -192
- data/agentf_improvements.md +0 -253
- data/agents.md +0 -14
- data/docs/api/core/index.md +0 -147
- data/docs/api/core/memory.md +0 -578
- data/docs/api/core/network.md +0 -553
- data/docs/api/core/result.md +0 -211
- data/docs/api/core/robot.md +0 -1667
- data/docs/api/core/state.md +0 -145
- data/docs/api/core/tool.md +0 -520
- data/docs/api/errors.md +0 -323
- data/docs/api/hooks.md +0 -469
- data/docs/api/index.md +0 -137
- data/docs/api/mcp/client.md +0 -387
- data/docs/api/mcp/index.md +0 -267
- data/docs/api/mcp/server.md +0 -252
- data/docs/api/mcp/transports.md +0 -339
- data/docs/api/messages/index.md +0 -126
- data/docs/api/messages/text-message.md +0 -170
- data/docs/api/messages/tool-call-message.md +0 -206
- data/docs/api/messages/tool-result-message.md +0 -242
- data/docs/api/messages/user-message.md +0 -232
- data/docs/api/skills.md +0 -423
- data/docs/api/streaming/context.md +0 -280
- data/docs/api/streaming/events.md +0 -185
- data/docs/api/streaming/index.md +0 -156
- data/docs/api/support.md +0 -846
- data/docs/architecture/core-concepts.md +0 -575
- data/docs/architecture/index.md +0 -252
- data/docs/architecture/message-flow.md +0 -366
- data/docs/architecture/network-orchestration.md +0 -478
- data/docs/architecture/robot-execution.md +0 -396
- data/docs/architecture/state-management.md +0 -410
- data/docs/assets/css/custom.css +0 -56
- data/docs/assets/images/robot_lab.jpg +0 -0
- data/docs/concepts.md +0 -641
- data/docs/examples/basic-chat.md +0 -258
- data/docs/examples/index.md +0 -425
- data/docs/examples/mcp-server.md +0 -403
- data/docs/examples/multi-robot-network.md +0 -476
- data/docs/examples/tool-usage.md +0 -468
- data/docs/getting-started/configuration.md +0 -645
- data/docs/getting-started/index.md +0 -56
- data/docs/getting-started/installation.md +0 -197
- data/docs/getting-started/quick-start.md +0 -293
- data/docs/guides/building-robots.md +0 -1187
- data/docs/guides/creating-networks.md +0 -736
- data/docs/guides/hooks.md +0 -1206
- data/docs/guides/index.md +0 -78
- data/docs/guides/knowledge.md +0 -219
- data/docs/guides/mcp-integration.md +0 -740
- data/docs/guides/memory.md +0 -458
- data/docs/guides/observability.md +0 -733
- data/docs/guides/streaming.md +0 -323
- data/docs/guides/using-tools.md +0 -780
- data/docs/index.md +0 -280
- data/docs/superpowers/plans/2026-05-06-agentskills.md +0 -1303
- data/docs/superpowers/specs/2026-05-06-agentskills-design.md +0 -247
- data/examples/.envrc +0 -3
- data/examples/01_simple_robot.rb +0 -31
- data/examples/02_tools.rb +0 -109
- data/examples/03_network.rb +0 -118
- data/examples/04_mcp.rb +0 -217
- data/examples/05_streaming.rb +0 -137
- data/examples/06_prompt_templates.rb +0 -325
- data/examples/07_network_memory.rb +0 -313
- data/examples/08_llm_config.rb +0 -162
- data/examples/09_chaining.rb +0 -251
- data/examples/10_memory.rb +0 -315
- data/examples/11_network_introspection.rb +0 -239
- data/examples/12_message_bus.rb +0 -67
- data/examples/13_spawn.rb +0 -85
- data/examples/14_rusty_circuit/.envrc +0 -1
- data/examples/14_rusty_circuit/.gitignore +0 -1
- data/examples/14_rusty_circuit/README.md +0 -1
- data/examples/14_rusty_circuit/comic.rb +0 -149
- data/examples/14_rusty_circuit/display.rb +0 -217
- data/examples/14_rusty_circuit/heckler.rb +0 -65
- data/examples/14_rusty_circuit/open_mic.rb +0 -132
- data/examples/14_rusty_circuit/prompts/open_mic_comic.md +0 -20
- data/examples/14_rusty_circuit/prompts/open_mic_heckler.md +0 -23
- data/examples/14_rusty_circuit/prompts/open_mic_scout.md +0 -20
- data/examples/14_rusty_circuit/scout.rb +0 -165
- data/examples/14_rusty_circuit/show.log +0 -234
- data/examples/15_memory_network_and_bus/.envrc +0 -1
- data/examples/15_memory_network_and_bus/README.md +0 -66
- data/examples/15_memory_network_and_bus/editor_in_chief.rb +0 -24
- data/examples/15_memory_network_and_bus/editorial_pipeline.rb +0 -213
- data/examples/15_memory_network_and_bus/linux_writer.rb +0 -80
- data/examples/15_memory_network_and_bus/os_editor.rb +0 -48
- data/examples/15_memory_network_and_bus/os_writer.rb +0 -49
- data/examples/15_memory_network_and_bus/output/combined_article.md +0 -11
- data/examples/15_memory_network_and_bus/output/final_article.md +0 -8
- data/examples/15_memory_network_and_bus/output/linux_draft.md +0 -7
- data/examples/15_memory_network_and_bus/output/mac_draft.md +0 -7
- data/examples/15_memory_network_and_bus/output/memory.json +0 -13
- data/examples/15_memory_network_and_bus/output/revision_1.md +0 -29
- data/examples/15_memory_network_and_bus/output/revision_2.md +0 -50
- data/examples/15_memory_network_and_bus/output/revision_3.md +0 -8
- data/examples/15_memory_network_and_bus/output/windows_draft.md +0 -7
- data/examples/15_memory_network_and_bus/prompts/os_advocate.md +0 -13
- data/examples/15_memory_network_and_bus/prompts/os_chief.md +0 -13
- data/examples/15_memory_network_and_bus/prompts/os_editor.md +0 -13
- data/examples/16_writers_room/.envrc +0 -1
- data/examples/16_writers_room/display.rb +0 -158
- data/examples/16_writers_room/output/.gitignore +0 -4
- data/examples/16_writers_room/output/README.md +0 -69
- data/examples/16_writers_room/output/opus_001.md +0 -263
- data/examples/16_writers_room/output/opus_001_notes.log +0 -470
- data/examples/16_writers_room/output/opus_002.md +0 -245
- data/examples/16_writers_room/output/opus_002_notes.log +0 -546
- data/examples/16_writers_room/output/opus_002_screenplay.md +0 -7989
- data/examples/16_writers_room/output/opus_002_screenplay_notes.md +0 -993
- data/examples/16_writers_room/prompts/screenplay_writer.md +0 -66
- data/examples/16_writers_room/prompts/writer.md +0 -37
- data/examples/16_writers_room/room.rb +0 -186
- data/examples/16_writers_room/tools.rb +0 -173
- data/examples/16_writers_room/writer.rb +0 -121
- data/examples/16_writers_room/writers_room.rb +0 -256
- data/examples/17_skills.rb +0 -234
- data/examples/18_rails/.envrc +0 -3
- data/examples/18_rails/.gitignore +0 -5
- data/examples/18_rails/Gemfile +0 -11
- data/examples/18_rails/README.md +0 -67
- data/examples/18_rails/Rakefile +0 -4
- data/examples/18_rails/app/controllers/application_controller.rb +0 -4
- data/examples/18_rails/app/controllers/chat_controller.rb +0 -50
- data/examples/18_rails/app/jobs/application_job.rb +0 -4
- data/examples/18_rails/app/jobs/robot_run_job.rb +0 -25
- data/examples/18_rails/app/models/application_record.rb +0 -5
- data/examples/18_rails/app/models/robot_lab_result.rb +0 -36
- data/examples/18_rails/app/models/robot_lab_thread.rb +0 -23
- data/examples/18_rails/app/robots/chat_robot.rb +0 -25
- data/examples/18_rails/app/tools/time_tool.rb +0 -9
- data/examples/18_rails/app/views/chat/_user_message.html.erb +0 -1
- data/examples/18_rails/app/views/chat/index.html.erb +0 -67
- data/examples/18_rails/app/views/layouts/application.html.erb +0 -49
- data/examples/18_rails/bin/dev +0 -7
- data/examples/18_rails/bin/rails +0 -6
- data/examples/18_rails/bin/setup +0 -15
- data/examples/18_rails/config/application.rb +0 -33
- data/examples/18_rails/config/cable.yml +0 -2
- data/examples/18_rails/config/database.yml +0 -5
- data/examples/18_rails/config/environment.rb +0 -4
- data/examples/18_rails/config/initializers/robot_lab.rb +0 -11
- data/examples/18_rails/config/routes.rb +0 -6
- data/examples/18_rails/config.ru +0 -4
- data/examples/18_rails/db/migrate/001_create_robot_lab_tables.rb +0 -32
- data/examples/19_token_tracking.rb +0 -138
- data/examples/20_circuit_breaker.rb +0 -147
- data/examples/21_learning_loop.rb +0 -181
- data/examples/22_context_compression.rb +0 -172
- data/examples/23_convergence.rb +0 -133
- data/examples/24_structured_delegation.rb +0 -151
- data/examples/25_history_search/conversation.jsonl +0 -30
- data/examples/25_history_search.rb +0 -133
- data/examples/26_document_store/api_versioning_adr.md +0 -52
- data/examples/26_document_store/incident_postmortem.md +0 -46
- data/examples/26_document_store/postgres_runbook.md +0 -49
- data/examples/26_document_store/redis_caching_guide.md +0 -48
- data/examples/26_document_store/sidekiq_guide.md +0 -51
- data/examples/26_document_store.rb +0 -140
- data/examples/27_incident_response/README.md +0 -65
- data/examples/27_incident_response/incident_response.rb +0 -261
- data/examples/28_mcp_discovery.rb +0 -113
- data/examples/29_ractor_tools.rb +0 -240
- data/examples/30_ractor_network.rb +0 -252
- data/examples/31_launch_assessment.rb +0 -246
- data/examples/32_newsletter_reader.rb +0 -193
- data/examples/33_stock_generator.rb +0 -80
- data/examples/33_stock_predictor.rb +0 -327
- data/examples/34_agentskills.rb +0 -76
- data/examples/35_hooks.rb +0 -266
- data/examples/README.md +0 -550
- data/examples/common.rb +0 -180
- data/examples/prompts/assistant.md +0 -5
- data/examples/prompts/audit_trail.md +0 -8
- data/examples/prompts/billing.md +0 -10
- data/examples/prompts/classifier.md +0 -7
- data/examples/prompts/comedian.md +0 -6
- data/examples/prompts/comedy_critic.md +0 -10
- data/examples/prompts/configurable.md +0 -9
- data/examples/prompts/dispatcher.md +0 -12
- data/examples/prompts/entity_extractor.md +0 -14
- data/examples/prompts/escalation.md +0 -42
- data/examples/prompts/frontmatter_mcp_test.md +0 -9
- data/examples/prompts/frontmatter_named_test.md +0 -5
- data/examples/prompts/frontmatter_tools_test.md +0 -6
- data/examples/prompts/general.md +0 -7
- data/examples/prompts/github_assistant.md +0 -14
- data/examples/prompts/helper.md +0 -4
- data/examples/prompts/incident_responder.md +0 -18
- data/examples/prompts/keyword_extractor.md +0 -11
- data/examples/prompts/llm_config_demo.md +0 -20
- data/examples/prompts/order_support.md +0 -35
- data/examples/prompts/os_advocate.md +0 -13
- data/examples/prompts/os_chief.md +0 -13
- data/examples/prompts/os_editor.md +0 -13
- data/examples/prompts/parameterized_main_test.md +0 -6
- data/examples/prompts/pii_redactor.md +0 -7
- data/examples/prompts/product_support.md +0 -37
- data/examples/prompts/runbook_protocol.md +0 -12
- data/examples/prompts/sentiment_analyzer.md +0 -12
- data/examples/prompts/skill_a_test.md +0 -4
- data/examples/prompts/skill_b_test.md +0 -4
- data/examples/prompts/skill_config_test.md +0 -5
- data/examples/prompts/skill_cycle_a_test.md +0 -6
- data/examples/prompts/skill_cycle_b_test.md +0 -6
- data/examples/prompts/skill_description_test.md +0 -4
- data/examples/prompts/skill_leaf_test.md +0 -4
- data/examples/prompts/skill_nested_test.md +0 -6
- data/examples/prompts/skill_refs_main_test.md +0 -6
- data/examples/prompts/skill_self_ref_test.md +0 -6
- data/examples/prompts/skill_with_mcp_test.md +0 -9
- data/examples/prompts/skill_with_params_test.md +0 -6
- data/examples/prompts/skill_with_robot_name_test.md +0 -5
- data/examples/prompts/skill_with_tools_test.md +0 -6
- data/examples/prompts/sre_compliance.md +0 -10
- data/examples/prompts/structured_output.md +0 -7
- data/examples/prompts/synthesizer.md +0 -17
- data/examples/prompts/technical.md +0 -10
- data/examples/prompts/template_with_skills_test.md +0 -6
- data/examples/prompts/triage.md +0 -22
- data/examples/ruboruby.md +0 -423
- data/examples/run_all.rb +0 -60
- data/examples/xyzzy.rb +0 -97
- data/logfile +0 -8
- data/mkdocs.yml +0 -209
- data/robot_concurrency.md +0 -38
- data/simple_acp_review.md +0 -298
- data/temp.md +0 -6
- data/tool_manifest_plan.md +0 -155
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
# Example 30: Ractor Network Scheduler
|
|
5
|
-
#
|
|
6
|
-
# Demonstrates Track 2 of RobotLab's Ractor parallelism: running a
|
|
7
|
-
# multi-robot pipeline under RactorNetworkScheduler, which dispatches
|
|
8
|
-
# independent tasks in parallel waves and respects depends_on ordering.
|
|
9
|
-
#
|
|
10
|
-
# Concepts covered:
|
|
11
|
-
# - Network.new(parallel_mode: :ractor) — opt the network into Ractor dispatch
|
|
12
|
-
# - Dependency waves — independent tasks run concurrently,
|
|
13
|
-
# dependent tasks wait for their wave
|
|
14
|
-
# - RobotSpec — frozen, Ractor-shareable robot descriptor
|
|
15
|
-
# - RactorNetworkScheduler — direct use for testing / custom wiring
|
|
16
|
-
# - Result Hash — run_pipeline returns { name => result }
|
|
17
|
-
#
|
|
18
|
-
# Part 1 — simulated scheduler, no LLM traffic.
|
|
19
|
-
# Overrides execute_spec with a sleep-based stub so the wave ordering and
|
|
20
|
-
# timing characteristics are visible without real LLM calls.
|
|
21
|
-
#
|
|
22
|
-
# Part 2 — Network.new(parallel_mode: :ractor) API walkthrough.
|
|
23
|
-
# Shows how to configure the network and inspects the dependency graph.
|
|
24
|
-
# No run() call is made, so no LLM traffic either.
|
|
25
|
-
#
|
|
26
|
-
# Part 3 — live LLM run (opt-in via RUN_LIVE).
|
|
27
|
-
# Expected to fail against ruby_llm today; the rescue clauses explain why.
|
|
28
|
-
#
|
|
29
|
-
# Usage:
|
|
30
|
-
# bundle exec ruby examples/30_ractor_network.rb # Parts 1 & 2
|
|
31
|
-
# RUN_LIVE=1 bundle exec ruby examples/30_ractor_network.rb # Parts 1, 2 & 3
|
|
32
|
-
|
|
33
|
-
require_relative "common"
|
|
34
|
-
require "robot_lab/ractor"
|
|
35
|
-
|
|
36
|
-
banner "Ractor Network Scheduler"
|
|
37
|
-
|
|
38
|
-
DIVIDER = ("─" * 54).freeze
|
|
39
|
-
|
|
40
|
-
# =============================================================================
|
|
41
|
-
# Part 1: Simulated scheduler — dependency waves and timing
|
|
42
|
-
# =============================================================================
|
|
43
|
-
#
|
|
44
|
-
# Pipeline topology:
|
|
45
|
-
#
|
|
46
|
-
# headline_finder ─────────────────────────────────┐
|
|
47
|
-
# ├──► report_writer
|
|
48
|
-
# background_brief ─────────────────────────────────┤
|
|
49
|
-
# │
|
|
50
|
-
# fact_checker ─────────────────────────────────┘
|
|
51
|
-
#
|
|
52
|
-
# Wave 1 — headline_finder, background_brief, fact_checker: parallel (none depend on each other)
|
|
53
|
-
# Wave 2 — report_writer: sequential (depends on all three)
|
|
54
|
-
#
|
|
55
|
-
# Simulated latencies (seconds):
|
|
56
|
-
LATENCIES = {
|
|
57
|
-
"headline_finder" => 0.60,
|
|
58
|
-
"background_brief" => 0.50,
|
|
59
|
-
"fact_checker" => 0.40,
|
|
60
|
-
"report_writer" => 0.70
|
|
61
|
-
}.freeze
|
|
62
|
-
#
|
|
63
|
-
# Expected times:
|
|
64
|
-
# Parallel — wave1 = max(0.60, 0.50, 0.40) = 0.60s
|
|
65
|
-
# wave2 = 0.70s
|
|
66
|
-
# total ≈ 1.30s
|
|
67
|
-
#
|
|
68
|
-
# Sequential — 0.60 + 0.50 + 0.40 + 0.70 = 2.20s
|
|
69
|
-
#
|
|
70
|
-
# Speedup ≈ 1.7×
|
|
71
|
-
|
|
72
|
-
section "Part 1: Simulated Parallel Run (no LLM traffic)"
|
|
73
|
-
|
|
74
|
-
# SimulatedScheduler overrides execute_spec so that instead of
|
|
75
|
-
# constructing a real Robot and calling the LLM, it just sleeps for
|
|
76
|
-
# the robot's configured latency and returns a synthetic result string.
|
|
77
|
-
# All dependency-ordering and wave-dispatch logic is inherited unchanged.
|
|
78
|
-
class SimulatedScheduler < RobotLab::RactorNetworkScheduler
|
|
79
|
-
private
|
|
80
|
-
|
|
81
|
-
def execute_spec(spec, _message)
|
|
82
|
-
delay = LATENCIES[spec.name] || 0.30
|
|
83
|
-
sleep delay
|
|
84
|
-
"#{spec.name}: completed after #{delay}s".freeze
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Build RobotSpec objects directly.
|
|
89
|
-
# (Normally Network#run_with_ractor_scheduler builds these from Task wrappers.)
|
|
90
|
-
def make_spec(name, deps = :none)
|
|
91
|
-
spec = RobotLab::RobotSpec.new(
|
|
92
|
-
name: name.freeze,
|
|
93
|
-
template: nil,
|
|
94
|
-
system_prompt: "You are the #{name} robot.".freeze,
|
|
95
|
-
config_hash: {}.freeze
|
|
96
|
-
)
|
|
97
|
-
{ spec: spec, depends_on: deps }
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
specs_with_deps = [
|
|
101
|
-
make_spec("headline_finder"),
|
|
102
|
-
make_spec("background_brief"),
|
|
103
|
-
make_spec("fact_checker"),
|
|
104
|
-
make_spec("report_writer", ["headline_finder", "background_brief", "fact_checker"])
|
|
105
|
-
]
|
|
106
|
-
|
|
107
|
-
memory = RobotLab::Memory.new
|
|
108
|
-
scheduler = SimulatedScheduler.new(memory: memory)
|
|
109
|
-
|
|
110
|
-
topic = "artificial intelligence regulation in 2025"
|
|
111
|
-
|
|
112
|
-
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
113
|
-
results = scheduler.run_pipeline(specs_with_deps, message: topic)
|
|
114
|
-
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
115
|
-
|
|
116
|
-
scheduler.shutdown
|
|
117
|
-
|
|
118
|
-
sequential_total = LATENCIES.values.sum
|
|
119
|
-
|
|
120
|
-
puts " Pipeline topology:"
|
|
121
|
-
puts " Wave 1 (parallel): headline_finder · background_brief · fact_checker"
|
|
122
|
-
puts " Wave 2 (sequential): report_writer ← waits for all three"
|
|
123
|
-
puts
|
|
124
|
-
|
|
125
|
-
puts " Results (Hash { name => result_string }):"
|
|
126
|
-
results.each { |name, val| puts " \"#{name}\" => #{val.inspect}" }
|
|
127
|
-
puts
|
|
128
|
-
|
|
129
|
-
puts " Wall time: #{"%.3f" % elapsed}s"
|
|
130
|
-
puts " Sequential equiv: #{"%.3f" % sequential_total}s (#{LATENCIES.map { |n, d| "#{n}=#{d}" }.join(' + ')})"
|
|
131
|
-
puts " Speedup: #{(sequential_total / elapsed).round(1)}×"
|
|
132
|
-
puts
|
|
133
|
-
|
|
134
|
-
# =============================================================================
|
|
135
|
-
# Part 2: Network.new(parallel_mode: :ractor) API
|
|
136
|
-
# =============================================================================
|
|
137
|
-
|
|
138
|
-
section "Part 2: Network.new(parallel_mode: :ractor)"
|
|
139
|
-
|
|
140
|
-
# When parallel_mode: :ractor is set on a Network, network.run(message:)
|
|
141
|
-
# routes through RactorNetworkScheduler instead of SimpleFlow::Pipeline.
|
|
142
|
-
# The default mode is :async (unchanged SimpleFlow behavior).
|
|
143
|
-
|
|
144
|
-
network = RobotLab::Network.new(name: "research_pipeline", parallel_mode: :ractor) do
|
|
145
|
-
task :headline_finder, RobotLab.build(name: "headline_finder",
|
|
146
|
-
system_prompt: "Find the 3 most relevant news headlines. Be concise.",
|
|
147
|
-
**llm_opts),
|
|
148
|
-
depends_on: :none
|
|
149
|
-
|
|
150
|
-
task :background_brief, RobotLab.build(name: "background_brief",
|
|
151
|
-
system_prompt: "Provide a 2-sentence background on the topic.",
|
|
152
|
-
**llm_opts),
|
|
153
|
-
depends_on: :none
|
|
154
|
-
|
|
155
|
-
task :fact_checker, RobotLab.build(name: "fact_checker",
|
|
156
|
-
system_prompt: "List 3 verifiable facts about the topic.",
|
|
157
|
-
**llm_opts),
|
|
158
|
-
depends_on: :none
|
|
159
|
-
|
|
160
|
-
task :report_writer, RobotLab.build(name: "report_writer",
|
|
161
|
-
system_prompt: "Synthesize the provided context into a 3-sentence report.",
|
|
162
|
-
**llm_opts),
|
|
163
|
-
depends_on: ["headline_finder", "background_brief", "fact_checker"]
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
puts " network.name => #{network.name.inspect}"
|
|
167
|
-
puts " network.parallel_mode => #{network.parallel_mode.inspect}"
|
|
168
|
-
puts
|
|
169
|
-
puts " Dependency graph (from network.pipeline.step_dependencies):"
|
|
170
|
-
|
|
171
|
-
network.pipeline.step_dependencies.each do |step, deps|
|
|
172
|
-
dep_str = deps.empty? ? "(entry point — Wave 1)" : "depends on: #{deps.join(', ')}"
|
|
173
|
-
puts " :#{step.to_s.ljust(18)} #{dep_str}"
|
|
174
|
-
end
|
|
175
|
-
puts
|
|
176
|
-
puts " When network.run(message: ...) is called:"
|
|
177
|
-
puts " • RactorNetworkScheduler is created with the network's memory"
|
|
178
|
-
puts " • Each task is converted to a frozen RobotSpec"
|
|
179
|
-
puts " • Wave 1 tasks are dispatched concurrently (Thread per task)"
|
|
180
|
-
puts " • Each Thread submits a RactorJob to the worker queue"
|
|
181
|
-
puts " • A Ractor worker pops the job, constructs a fresh Robot from the"
|
|
182
|
-
puts " spec, calls robot.run(message), and returns the frozen result"
|
|
183
|
-
puts " • Wave 2 begins once all Wave 1 results are available"
|
|
184
|
-
puts " • Return value is a Hash { robot_name => result_string }"
|
|
185
|
-
puts
|
|
186
|
-
|
|
187
|
-
# =============================================================================
|
|
188
|
-
# Part 3: Live LLM run (opt-in via RUN_LIVE=1)
|
|
189
|
-
# =============================================================================
|
|
190
|
-
|
|
191
|
-
unless ENV["RUN_LIVE"]
|
|
192
|
-
section "Part 3: Live LLM Run"
|
|
193
|
-
puts " Set RUN_LIVE=1 (with LM Studio running) to attempt the real pipeline."
|
|
194
|
-
puts " Expected behavior: headline_finder, background_brief, and"
|
|
195
|
-
puts " fact_checker run in parallel; report_writer follows."
|
|
196
|
-
puts
|
|
197
|
-
puts " Heads-up: this is expected to fail today — see the rescue clauses"
|
|
198
|
-
puts " below. ruby_llm keeps Procs in class-level state, which cannot"
|
|
199
|
-
puts " cross a Ractor boundary. Part 1 shows the wave ordering that Part 3"
|
|
200
|
-
puts " will exercise once ruby_llm is Ractor-safe."
|
|
201
|
-
puts
|
|
202
|
-
hr
|
|
203
|
-
puts "Example 30 complete."
|
|
204
|
-
exit 0
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
require_lms!
|
|
208
|
-
|
|
209
|
-
section "Part 3: Live LLM Run (RUN_LIVE set)"
|
|
210
|
-
|
|
211
|
-
puts " Running 4-robot research pipeline on:"
|
|
212
|
-
puts " \"#{topic}\""
|
|
213
|
-
puts
|
|
214
|
-
puts " Wave 1 (parallel): headline_finder · background_brief · fact_checker"
|
|
215
|
-
puts " Wave 2: report_writer"
|
|
216
|
-
puts
|
|
217
|
-
|
|
218
|
-
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
219
|
-
|
|
220
|
-
begin
|
|
221
|
-
result = network.run(message: topic)
|
|
222
|
-
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
223
|
-
|
|
224
|
-
puts " Completed in #{"%.2f" % elapsed}s"
|
|
225
|
-
puts
|
|
226
|
-
puts " Results:"
|
|
227
|
-
result.each do |name, text|
|
|
228
|
-
snippet = text.to_s.strip.lines.first.to_s.strip
|
|
229
|
-
puts " [#{name}] #{snippet[0..90]}#{snippet.length > 90 ? '…' : ''}"
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
rescue Ractor::IsolationError, Ractor::Error => e
|
|
233
|
-
puts " Ractor error: #{e.class}: #{e.message[0..100]}"
|
|
234
|
-
puts
|
|
235
|
-
puts " ruby_llm is not yet Ractor-safe: it uses Procs in class"
|
|
236
|
-
puts " definitions that cannot cross Ractor boundaries."
|
|
237
|
-
puts " Track this at: https://github.com/crmne/ruby_llm"
|
|
238
|
-
|
|
239
|
-
rescue RobotLab::Error => e
|
|
240
|
-
if e.message.include?("un-shareable Proc")
|
|
241
|
-
puts " Live LLM runs require ruby_llm to be Ractor-safe."
|
|
242
|
-
puts " ruby_llm stores Procs in class-level hooks that cannot cross"
|
|
243
|
-
puts " Ractor boundaries. Part 1 (SimulatedScheduler) demonstrates wave"
|
|
244
|
-
puts " ordering today; Part 3 will work once ruby_llm gains Ractor support."
|
|
245
|
-
else
|
|
246
|
-
puts " RobotLab::Error: #{e.message}"
|
|
247
|
-
end
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
puts
|
|
251
|
-
hr
|
|
252
|
-
puts "Example 30 complete."
|
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
# Example 31: Product Launch Assessment — 6 Parallel Analysts, Cap of 4
|
|
5
|
-
#
|
|
6
|
-
# Six specialist robots evaluate a product launch simultaneously.
|
|
7
|
-
# max_concurrent_robots: 4 ensures at most 4 LLM API calls are in-flight
|
|
8
|
-
# at once. Robots 5 and 6 queue behind the Async::Semaphore and start as
|
|
9
|
-
# soon as any of the first 4 finishes — providing natural back-pressure
|
|
10
|
-
# without slowing the pipeline more than necessary.
|
|
11
|
-
#
|
|
12
|
-
# Architecture:
|
|
13
|
-
#
|
|
14
|
-
# ┌──────────────────────────────────────────────────────────────────────┐
|
|
15
|
-
# │ PARALLEL ANALYSIS PHASE (max_concurrent_robots: 4) │
|
|
16
|
-
# │ │
|
|
17
|
-
# │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
|
18
|
-
# │ │ Market │ │ Compet. │ │ Tech │ │ Risk │ slots 1-4 │
|
|
19
|
-
# │ │ Analyst │ │ Analyst │ │ Reviewer │ │ Assessor │ │
|
|
20
|
-
# │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
|
21
|
-
# │ │ │ │ │ │
|
|
22
|
-
# │ start start start start │
|
|
23
|
-
# │ │
|
|
24
|
-
# │ ┌──────────┐ ┌──────────┐ │
|
|
25
|
-
# │ │Financial │ │ Legal │ queued — start when a slot opens │
|
|
26
|
-
# │ │ Reviewer │ │ Reviewer │ │
|
|
27
|
-
# │ └────┬─────┘ └────┬─────┘ │
|
|
28
|
-
# │ │ │ │
|
|
29
|
-
# │ (deferred) (deferred) │
|
|
30
|
-
# │ │
|
|
31
|
-
# │ ┌─────────────────────────────────────────────────────────────┐ │
|
|
32
|
-
# │ │ SHARED MEMORY │ │
|
|
33
|
-
# │ │ :market :competitive :tech :risk :financial :legal │ │
|
|
34
|
-
# │ └─────────────────────────────────────────────────────────────┘ │
|
|
35
|
-
# │ │ │
|
|
36
|
-
# │ ▼ │
|
|
37
|
-
# │ ┌─────────────────────────────────────────────────────────────┐ │
|
|
38
|
-
# │ │ Launch Director │ │
|
|
39
|
-
# │ │ Blocks on reactive memory until all 6 findings arrive, │ │
|
|
40
|
-
# │ │ then issues a GO / NO-GO recommendation. │ │
|
|
41
|
-
# │ └─────────────────────────────────────────────────────────────┘ │
|
|
42
|
-
# └──────────────────────────────────────────────────────────────────────┘
|
|
43
|
-
#
|
|
44
|
-
# Key config:
|
|
45
|
-
# RunConfig.new(max_concurrent_robots: 4)
|
|
46
|
-
#
|
|
47
|
-
# Usage:
|
|
48
|
-
# ruby examples/31_launch_assessment.rb
|
|
49
|
-
|
|
50
|
-
require_relative "common"
|
|
51
|
-
|
|
52
|
-
# ── AnalystRobot ────────────────────────────────────────────────────────────────
|
|
53
|
-
#
|
|
54
|
-
# Runs its LLM call, writes the verdict to shared memory, and logs a timing line
|
|
55
|
-
# so you can see when the semaphore releases robots 5 and 6.
|
|
56
|
-
|
|
57
|
-
class AnalystRobot < RobotLab::Robot
|
|
58
|
-
attr_reader :memory_key
|
|
59
|
-
attr_writer :shared_memory
|
|
60
|
-
|
|
61
|
-
def initialize(name:, memory_key:, role:)
|
|
62
|
-
super(
|
|
63
|
-
name: name,
|
|
64
|
-
**llm_opts,
|
|
65
|
-
system_prompt: "You are a #{role}. " \
|
|
66
|
-
"Review the product brief in 2-3 crisp sentences from your area of expertise. " \
|
|
67
|
-
"Close with a one-word verdict: READY or NOT-READY."
|
|
68
|
-
)
|
|
69
|
-
@memory_key = memory_key
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def call(result)
|
|
73
|
-
brief = extract_brief(result)
|
|
74
|
-
started = Time.now
|
|
75
|
-
puts " [#{name}] started at +#{"%.1f" % (started - $run_start)}s"
|
|
76
|
-
|
|
77
|
-
verdict = run(brief).reply.strip
|
|
78
|
-
|
|
79
|
-
elapsed = "%.1f" % (Time.now - started)
|
|
80
|
-
puts " [#{name}] finished in #{elapsed}s — #{verdict.split.last(2).join(" ")}"
|
|
81
|
-
|
|
82
|
-
if @shared_memory
|
|
83
|
-
@shared_memory.current_writer = name
|
|
84
|
-
@shared_memory.set(@memory_key, verdict)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
result.with_context(name.to_sym, verdict).continue(verdict)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
private
|
|
91
|
-
|
|
92
|
-
def extract_brief(result)
|
|
93
|
-
case result.value
|
|
94
|
-
when Hash then result.value[:message].to_s
|
|
95
|
-
when RobotLab::RobotResult then result.value.reply.to_s
|
|
96
|
-
else result.value.to_s
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# ── LaunchDirector ──────────────────────────────────────────────────────────────
|
|
102
|
-
#
|
|
103
|
-
# Waits for all 6 findings via reactive memory, then issues the final call.
|
|
104
|
-
# SimpleFlow guarantees the six analyst tasks are done before this task runs,
|
|
105
|
-
# so the memory.get is effectively a non-blocking read by the time we arrive here.
|
|
106
|
-
|
|
107
|
-
class LaunchDirector < RobotLab::Robot
|
|
108
|
-
attr_writer :shared_memory
|
|
109
|
-
|
|
110
|
-
FINDING_KEYS = %i[market competitive tech risk financial legal].freeze
|
|
111
|
-
|
|
112
|
-
def call(result)
|
|
113
|
-
puts " [#{name}] reading all findings from shared memory..."
|
|
114
|
-
findings = collect_findings
|
|
115
|
-
|
|
116
|
-
prompt = <<~PROMPT
|
|
117
|
-
Six specialist analysts have reviewed our product launch readiness.
|
|
118
|
-
Based on their findings, issue a final GO or NO-GO recommendation
|
|
119
|
-
in 3-5 sentences. Be direct and specific about the key deciding factors.
|
|
120
|
-
|
|
121
|
-
Market analysis: #{findings[:market] || "(not received)"}
|
|
122
|
-
Competitive analysis: #{findings[:competitive] || "(not received)"}
|
|
123
|
-
Technical review: #{findings[:tech] || "(not received)"}
|
|
124
|
-
Risk assessment: #{findings[:risk] || "(not received)"}
|
|
125
|
-
Financial review: #{findings[:financial] || "(not received)"}
|
|
126
|
-
Legal review: #{findings[:legal] || "(not received)"}
|
|
127
|
-
|
|
128
|
-
Begin your response with "GO -" or "NO-GO -".
|
|
129
|
-
PROMPT
|
|
130
|
-
|
|
131
|
-
recommendation = run(prompt).reply.strip
|
|
132
|
-
@shared_memory.set(:recommendation, recommendation)
|
|
133
|
-
puts " [#{name}] recommendation ready"
|
|
134
|
-
|
|
135
|
-
result.with_context(:recommendation, recommendation).continue(recommendation)
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
private
|
|
139
|
-
|
|
140
|
-
# Memory#get(wait:) RAISES RobotLab::AwaitTimeout on expiry — it never
|
|
141
|
-
# returns a :timeout sentinel. Rescue it and re-read without wait:, which
|
|
142
|
-
# never blocks and omits keys that were never written, so a stalled analyst
|
|
143
|
-
# degrades the recommendation instead of killing the director.
|
|
144
|
-
def collect_findings
|
|
145
|
-
@shared_memory.get(*FINDING_KEYS, wait: 120)
|
|
146
|
-
rescue RobotLab::AwaitTimeout => e
|
|
147
|
-
puts " [#{name}] WARNING: #{e.message}"
|
|
148
|
-
partial = @shared_memory.get(*FINDING_KEYS)
|
|
149
|
-
missing = FINDING_KEYS - partial.keys
|
|
150
|
-
puts " [#{name}] Proceeding without: #{missing.join(', ')}" if missing.any?
|
|
151
|
-
partial
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
# ── Product Brief ───────────────────────────────────────────────────────────────
|
|
156
|
-
|
|
157
|
-
PRODUCT_BRIEF = <<~BRIEF
|
|
158
|
-
Product: "Orion" — an AI-powered project management tool that auto-generates
|
|
159
|
-
sprint plans from Jira backlogs, detects scope creep in real-time, and integrates
|
|
160
|
-
with GitHub and Slack via webhooks. SaaS pricing: $25/seat/month, 14-day free trial.
|
|
161
|
-
Target: mid-size engineering teams (20-200 developers). Launch date: 6 weeks out.
|
|
162
|
-
Beta: 12 paying customers, 94% satisfaction, 0 critical bugs open. SOC 2 Type I
|
|
163
|
-
certification in progress, expected within 30 days.
|
|
164
|
-
BRIEF
|
|
165
|
-
|
|
166
|
-
# ── Build the six analysts ───────────────────────────────────────────────────────
|
|
167
|
-
|
|
168
|
-
ANALYSTS = [
|
|
169
|
-
{ name: "market_analyst", key: :market, role: "market opportunity analyst" },
|
|
170
|
-
{ name: "competitive_analyst", key: :competitive, role: "competitive intelligence analyst" },
|
|
171
|
-
{ name: "tech_reviewer", key: :tech, role: "technical readiness and quality reviewer" },
|
|
172
|
-
{ name: "risk_assessor", key: :risk, role: "product risk assessment specialist" },
|
|
173
|
-
{ name: "financial_reviewer", key: :financial, role: "financial viability and pricing analyst" },
|
|
174
|
-
{ name: "legal_reviewer", key: :legal, role: "legal, compliance, and IP reviewer" },
|
|
175
|
-
].freeze
|
|
176
|
-
|
|
177
|
-
analyst_robots = ANALYSTS.map do |spec|
|
|
178
|
-
AnalystRobot.new(name: spec[:name], memory_key: spec[:key], role: spec[:role])
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
director = LaunchDirector.new(
|
|
182
|
-
name: "launch_director",
|
|
183
|
-
**llm_opts,
|
|
184
|
-
system_prompt: "You are the VP of Product making the final launch call."
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
# ── Network — note the concurrency cap ──────────────────────────────────────────
|
|
188
|
-
|
|
189
|
-
config = RobotLab::RunConfig.new(max_concurrent_robots: 4)
|
|
190
|
-
|
|
191
|
-
analyst_names = analyst_robots.map { |r| r.name.to_sym }
|
|
192
|
-
|
|
193
|
-
network = RobotLab.create_network(name: "launch_assessment", config: config) do
|
|
194
|
-
analyst_robots.each do |robot|
|
|
195
|
-
task robot.name.to_sym, robot, depends_on: :none
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
task :launch_director, director, depends_on: analyst_names
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
# Assign shared memory so each robot can write to it directly
|
|
202
|
-
shared_memory = network.memory
|
|
203
|
-
(analyst_robots + [director]).each { |r| r.shared_memory = shared_memory }
|
|
204
|
-
|
|
205
|
-
# Subscribe for a memory-level audit trail
|
|
206
|
-
analyst_robots.each do |robot|
|
|
207
|
-
network.memory.subscribe(robot.memory_key) do |change|
|
|
208
|
-
puts " [memory] :#{change.key} written by #{change.writer}"
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
# ── Run ─────────────────────────────────────────────────────────────────────────
|
|
213
|
-
|
|
214
|
-
banner "Product Launch Assessment"
|
|
215
|
-
puts " 6 specialist analysts in parallel, max_concurrent_robots: 4"
|
|
216
|
-
puts
|
|
217
|
-
puts "Pipeline:"
|
|
218
|
-
puts network.visualize
|
|
219
|
-
puts
|
|
220
|
-
puts "Concurrency config: #{config.inspect}"
|
|
221
|
-
puts
|
|
222
|
-
puts "Product brief:"
|
|
223
|
-
puts PRODUCT_BRIEF.strip.gsub(/^/, " ")
|
|
224
|
-
puts
|
|
225
|
-
section "Running — Analysts 5 and 6 Queue Until a Semaphore Slot Opens"
|
|
226
|
-
|
|
227
|
-
$run_start = Time.now
|
|
228
|
-
result = network.run(message: PRODUCT_BRIEF)
|
|
229
|
-
elapsed = "%.1f" % (Time.now - $run_start)
|
|
230
|
-
|
|
231
|
-
puts
|
|
232
|
-
hr
|
|
233
|
-
puts "All analysts complete. Total wall time: #{elapsed}s"
|
|
234
|
-
hr
|
|
235
|
-
|
|
236
|
-
section "Launch Director Recommendation"
|
|
237
|
-
puts network.memory[:recommendation]
|
|
238
|
-
puts
|
|
239
|
-
section "Individual Analyst Verdicts"
|
|
240
|
-
analyst_robots.each do |robot|
|
|
241
|
-
label = robot.name.gsub("_", " ").upcase
|
|
242
|
-
finding = network.memory.get(robot.memory_key).to_s
|
|
243
|
-
puts "#{label}"
|
|
244
|
-
puts finding
|
|
245
|
-
puts
|
|
246
|
-
end
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
# Example 32: Newsletter Issue Retriever
|
|
5
|
-
#
|
|
6
|
-
# NOTE: this is a plain utility script, not a RobotLab demo — it uses no part
|
|
7
|
-
# of the library. It lives here as the content feeder for other experiments.
|
|
8
|
-
# Read examples 01-31 for library capabilities.
|
|
9
|
-
#
|
|
10
|
-
# Fetches all unprocessed issues from multiple Ruby newsletters via RSS
|
|
11
|
-
# and saves each as a Markdown file in a Markdown clippings folder.
|
|
12
|
-
#
|
|
13
|
-
# - Processes oldest unprocessed issue first (across all feeds)
|
|
14
|
-
# - Filename: <newsletter-name>_YYYYMMDD.md
|
|
15
|
-
# - Includes YAML frontmatter with source URL (compatible with Clippings workflow)
|
|
16
|
-
# - Tracks processed issue URLs in ~/.robot_lab/newsletter_processed.yaml
|
|
17
|
-
#
|
|
18
|
-
# Usage:
|
|
19
|
-
# ruby examples/32_newsletter_reader.rb
|
|
20
|
-
# CLIPPINGS_DIR=~/Notes/Clippings ruby examples/32_newsletter_reader.rb
|
|
21
|
-
|
|
22
|
-
require "net/http"
|
|
23
|
-
require "open3"
|
|
24
|
-
require "time"
|
|
25
|
-
require "uri"
|
|
26
|
-
require "yaml"
|
|
27
|
-
require "fileutils"
|
|
28
|
-
require "rexml/document"
|
|
29
|
-
require "rexml/xpath"
|
|
30
|
-
|
|
31
|
-
NEWSLETTER_RSS_URLS = [
|
|
32
|
-
"https://rss.beehiiv.com/feeds/MTJunJRFxo.xml", # Ruby AI News
|
|
33
|
-
"https://cprss.s3.amazonaws.com/rubyweekly.com.xml" # Ruby Weekly
|
|
34
|
-
].freeze
|
|
35
|
-
|
|
36
|
-
CLIPPINGS_DIR = File.expand_path(ENV.fetch("CLIPPINGS_DIR", "~/Documents/Clippings"))
|
|
37
|
-
PROCESSED_STATE_FILE = File.join(Dir.home, ".robot_lab", "newsletter_processed.yaml")
|
|
38
|
-
|
|
39
|
-
# Tracks which newsletter issue URLs have been saved.
|
|
40
|
-
class ProcessedIssues
|
|
41
|
-
def initialize(path: PROCESSED_STATE_FILE)
|
|
42
|
-
@path = path
|
|
43
|
-
@urls = load_urls
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def processed?(url)
|
|
47
|
-
@urls.include?(url)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def mark_processed(url)
|
|
51
|
-
@urls << url
|
|
52
|
-
FileUtils.mkdir_p(File.dirname(@path))
|
|
53
|
-
File.write(@path, YAML.dump(@urls.uniq))
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def count = @urls.size
|
|
57
|
-
|
|
58
|
-
private
|
|
59
|
-
|
|
60
|
-
def load_urls
|
|
61
|
-
return [] unless File.exist?(@path)
|
|
62
|
-
Array(YAML.safe_load(File.read(@path)) || [])
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Fetches a single RSS feed URL and returns its items (unsorted).
|
|
67
|
-
# Each item: { title:, url:, pub_date:, published_at:, html:, channel_name: }
|
|
68
|
-
def fetch_rss_items(url)
|
|
69
|
-
uri = URI(url)
|
|
70
|
-
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |h| h.get(uri.request_uri) }
|
|
71
|
-
raise "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
72
|
-
|
|
73
|
-
doc = REXML::Document.new(response.body)
|
|
74
|
-
ns = { "content" => "http://purl.org/rss/1.0/modules/content/" }
|
|
75
|
-
|
|
76
|
-
channel_title = REXML::XPath.first(doc, "//channel/title")&.text&.strip || "newsletter"
|
|
77
|
-
|
|
78
|
-
REXML::XPath.match(doc, "//channel/item").filter_map do |item|
|
|
79
|
-
item_url = REXML::XPath.first(item, "link")&.text&.strip
|
|
80
|
-
title = REXML::XPath.first(item, "title")&.text&.strip
|
|
81
|
-
pub_date = REXML::XPath.first(item, "pubDate")&.text&.strip
|
|
82
|
-
html = REXML::XPath.first(item, "content:encoded", ns)&.text ||
|
|
83
|
-
REXML::XPath.first(item, "description")&.text || ""
|
|
84
|
-
|
|
85
|
-
next unless item_url && title && pub_date
|
|
86
|
-
|
|
87
|
-
{
|
|
88
|
-
title: title,
|
|
89
|
-
url: item_url,
|
|
90
|
-
pub_date: pub_date,
|
|
91
|
-
published_at: Time.parse(pub_date),
|
|
92
|
-
html: html,
|
|
93
|
-
channel_name: channel_title
|
|
94
|
-
}
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# Fetches all configured RSS feeds and returns items sorted oldest-first.
|
|
99
|
-
def fetch_all_rss_items
|
|
100
|
-
NEWSLETTER_RSS_URLS.flat_map do |feed_url|
|
|
101
|
-
print " #{feed_url}... "
|
|
102
|
-
items = fetch_rss_items(feed_url)
|
|
103
|
-
puts "#{items.size} issues"
|
|
104
|
-
items
|
|
105
|
-
end.sort_by { |item| item[:published_at] }
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# Converts HTML to Markdown via html2markdown CLI, stripping UTM params.
|
|
109
|
-
def html_to_markdown(html)
|
|
110
|
-
md, = Open3.capture3(
|
|
111
|
-
"html2markdown",
|
|
112
|
-
stdin_data: html
|
|
113
|
-
)
|
|
114
|
-
md.gsub(/\]\(([^)]+)\)/) do
|
|
115
|
-
url = $1
|
|
116
|
-
if url.include?("?")
|
|
117
|
-
base, query = url.split("?", 2)
|
|
118
|
-
kept = query.split("&").reject { |p| p.start_with?("utm_") }
|
|
119
|
-
"](#{kept.empty? ? base : "#{base}?#{kept.join("&")}"})"
|
|
120
|
-
else
|
|
121
|
-
"](#{url})"
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# Builds the output filename: <newsletter-name>_YYYYMMDD.md
|
|
127
|
-
def output_filename(channel_name, published_at)
|
|
128
|
-
safe_name = channel_name.downcase.gsub(/[^a-z0-9]+/, "_").delete_suffix("_")
|
|
129
|
-
date_str = published_at.strftime("%Y%m%d")
|
|
130
|
-
"#{safe_name}_#{date_str}.md"
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
# Wraps markdown content in YAML frontmatter compatible with the Clippings workflow.
|
|
134
|
-
def wrap_with_frontmatter(title:, url:, pub_date:, body:)
|
|
135
|
-
date = Time.parse(pub_date).strftime("%Y-%m-%d")
|
|
136
|
-
<<~MD
|
|
137
|
-
---
|
|
138
|
-
source: #{url}
|
|
139
|
-
title: "#{title.gsub('"', '\\"')}"
|
|
140
|
-
date: #{date}
|
|
141
|
-
---
|
|
142
|
-
|
|
143
|
-
# #{title}
|
|
144
|
-
|
|
145
|
-
#{body.strip}
|
|
146
|
-
MD
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
150
|
-
|
|
151
|
-
puts "=" * 60
|
|
152
|
-
puts "Example 32: Newsletter Issue Retriever"
|
|
153
|
-
puts "=" * 60
|
|
154
|
-
puts
|
|
155
|
-
|
|
156
|
-
FileUtils.mkdir_p(CLIPPINGS_DIR)
|
|
157
|
-
state = ProcessedIssues.new
|
|
158
|
-
|
|
159
|
-
puts "Fetching #{NEWSLETTER_RSS_URLS.size} RSS feeds..."
|
|
160
|
-
all_items = fetch_all_rss_items
|
|
161
|
-
pending = all_items.reject { |item| state.processed?(item[:url]) }
|
|
162
|
-
puts "#{all_items.size} total issues found across all feeds."
|
|
163
|
-
|
|
164
|
-
if pending.empty?
|
|
165
|
-
puts "All issues already saved. Nothing to do."
|
|
166
|
-
exit
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
puts "#{pending.size} unprocessed (#{state.count} already done). Saving oldest-first."
|
|
170
|
-
puts
|
|
171
|
-
|
|
172
|
-
pending.each_with_index do |item, idx|
|
|
173
|
-
filename = output_filename(item[:channel_name], item[:published_at])
|
|
174
|
-
filepath = File.join(CLIPPINGS_DIR, filename)
|
|
175
|
-
|
|
176
|
-
print "[#{idx + 1}/#{pending.size}] #{item[:title]} (#{item[:published_at].strftime("%Y-%m-%d")})... "
|
|
177
|
-
|
|
178
|
-
body = html_to_markdown(item[:html])
|
|
179
|
-
content = wrap_with_frontmatter(
|
|
180
|
-
title: item[:title],
|
|
181
|
-
url: item[:url],
|
|
182
|
-
pub_date: item[:pub_date],
|
|
183
|
-
body: body
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
File.write(filepath, content)
|
|
187
|
-
state.mark_processed(item[:url])
|
|
188
|
-
|
|
189
|
-
puts "saved → #{filename}"
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
puts
|
|
193
|
-
puts "Done. #{pending.size} issue(s) saved to #{CLIPPINGS_DIR}"
|