pikuri-core 0.0.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/pikuri/agent/chat_transport.rb +73 -93
  4. data/lib/pikuri/agent/configurator.rb +46 -106
  5. data/lib/pikuri/agent/context_window_detector.rb +44 -85
  6. data/lib/pikuri/agent/control/cancellable.rb +87 -66
  7. data/lib/pikuri/agent/control/interloper.rb +127 -105
  8. data/lib/pikuri/agent/control/step_limit.rb +25 -41
  9. data/lib/pikuri/agent/control.rb +14 -34
  10. data/lib/pikuri/agent/event.rb +123 -188
  11. data/lib/pikuri/agent/extension.rb +118 -94
  12. data/lib/pikuri/agent/extension_context.rb +50 -77
  13. data/lib/pikuri/agent/history.rb +653 -0
  14. data/lib/pikuri/agent/listener/rate_limited.rb +40 -66
  15. data/lib/pikuri/agent/listener/terminal.rb +143 -117
  16. data/lib/pikuri/agent/listener/token_log.rb +101 -140
  17. data/lib/pikuri/agent/listener.rb +23 -43
  18. data/lib/pikuri/agent/listener_list.rb +26 -47
  19. data/lib/pikuri/agent/synthesizer.rb +45 -87
  20. data/lib/pikuri/agent.rb +816 -474
  21. data/lib/pikuri/bundler_env.rb +68 -0
  22. data/lib/pikuri/extractor/html.rb +63 -110
  23. data/lib/pikuri/extractor/passthrough.rb +20 -30
  24. data/lib/pikuri/extractor.rb +93 -154
  25. data/lib/pikuri/file_type.rb +63 -135
  26. data/lib/pikuri/finalizers.rb +32 -47
  27. data/lib/pikuri/paths.rb +104 -13
  28. data/lib/pikuri/ruby_llm_patches.rb +106 -0
  29. data/lib/pikuri/sanitizer.rb +45 -67
  30. data/lib/pikuri/subprocess.rb +75 -119
  31. data/lib/pikuri/testing.rb +296 -0
  32. data/lib/pikuri/tool/calculator.rb +56 -66
  33. data/lib/pikuri/tool/execute_context.rb +42 -0
  34. data/lib/pikuri/tool/fetch.rb +51 -77
  35. data/lib/pikuri/tool/parameters.rb +21 -29
  36. data/lib/pikuri/tool/scraper.rb +55 -97
  37. data/lib/pikuri/tool/search/brave.rb +52 -80
  38. data/lib/pikuri/tool/search/duckduckgo.rb +59 -91
  39. data/lib/pikuri/tool/search/engines.rb +230 -97
  40. data/lib/pikuri/tool/search/exa.rb +56 -90
  41. data/lib/pikuri/tool/search/rate_limiter.rb +61 -38
  42. data/lib/pikuri/tool/search/result.rb +10 -15
  43. data/lib/pikuri/tool/trifecta_legs.rb +217 -0
  44. data/lib/pikuri/tool/web_scrape.rb +38 -54
  45. data/lib/pikuri/tool/web_search.rb +100 -24
  46. data/lib/pikuri/tool.rb +140 -65
  47. data/lib/pikuri/trifecta/contribution.rb +43 -0
  48. data/lib/pikuri/trifecta/node.rb +47 -0
  49. data/lib/pikuri/trifecta/report.rb +230 -0
  50. data/lib/pikuri/trifecta.rb +127 -0
  51. data/lib/pikuri/url_cache.rb +33 -49
  52. data/lib/pikuri/version.rb +1 -1
  53. data/lib/pikuri-core.rb +72 -88
  54. data/prompts/agent-loop.txt +5 -0
  55. data/prompts/pikuri-chat.txt +3 -12
  56. metadata +14 -3
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Trifecta
5
+ # The result of walking one agent tree: a {Node} with its legs folded — its
6
+ # own tools unioned with everything propagated up from its sub-agents. What
7
+ # {Pikuri::Trifecta.walk} returns, mirroring the tree shape so a host can
8
+ # both classify and display it:
9
+ #
10
+ # report = Pikuri::Trifecta.walk(node)
11
+ # report.tree_verdict # => :soft
12
+ # report.worst_path.map(&:label) # => ["pikuri-computer", "RESEARCHER"]
13
+ # puts report.render # the human-readable map
14
+ #
15
+ # == Implementation details
16
+ #
17
+ # {#verdict} is *derived, never stored*: +Data#with+ would otherwise let a
18
+ # copy carry a severity that contradicts its own legs. It reads presence
19
+ # (leg-absence, correct for any level ever added) and matches strength
20
+ # exhaustively across all three graded axes, so introducing a lattice level
21
+ # without placing it in the grid raises on the first wiring that reaches it
22
+ # rather than being silently absorbed into +:soft+.
23
+ #
24
+ # Immutable.
25
+ #
26
+ # @!attribute [r] label
27
+ # @return [String] this agent's name (see {Node#label})
28
+ # @!attribute [r] legs
29
+ # @return [Pikuri::Tool::TrifectaLegs] folded legs — own tools plus
30
+ # everything propagated up from {#children}. What {#verdict} reads.
31
+ # @!attribute [r] tool_legs
32
+ # @return [Hash{String=>Pikuri::Tool::TrifectaLegs}] carried through from
33
+ # the {Node} so {#render} can print the tools as leaves
34
+ # @!attribute [r] children
35
+ # @return [Array<Report>] one per sub-agent, already walked
36
+ # @!attribute [r] channel_egress
37
+ # @return [Symbol, nil] the parent edge's gate level (see
38
+ # {Node#channel_egress})
39
+ class Report < Data.define(:label, :legs, :tool_legs, :children, :channel_egress)
40
+ # Verdicts, least to most severe. The only ordering outside
41
+ # {Pikuri::Tool::TrifectaLegs}.
42
+ #
43
+ # @return [Array<Symbol>]
44
+ SEVERITY = %i[silent soft loud].freeze
45
+
46
+ # Closing pointer of {#advisory}. Names the chapter by *slug*, not by
47
+ # ordinal: book chapters are ordered only by `book/README.md`, so this
48
+ # path survives a reordering that would silently falsify a number.
49
+ #
50
+ # @return [String]
51
+ FOOTER = 'See book/trifecta-detector.md in this repo.'
52
+
53
+ # How dangerous *this node* is — the tree's verdict is {#tree_verdict}.
54
+ #
55
+ # +:loud+ needs all three legs at full strength; +:soft+ is any complete
56
+ # trifecta whose untrusted or egress leg is attenuated; +:silent+ is a
57
+ # missing leg. Absence of a warning is not evidence of safety: this
58
+ # reasons about capability presence, not data flow.
59
+ #
60
+ # @return [Symbol] +:silent+, +:soft+ or +:loud+
61
+ # @raise [NoMatchingPatternError] if a lattice level exists that this grid
62
+ # does not place — deliberate, so a new level cannot be absorbed silently
63
+ def verdict
64
+ return :silent unless legs.private && legs.untrusted != :none && legs.egress?
65
+
66
+ case [legs.untrusted, legs.egress_payload_review, legs.egress_destination]
67
+ in [:hard, :unreviewed, :attacker_reachable] then :loud
68
+ in [:hard, :unreviewed, :tool_vouched] then :soft
69
+ in [:hard, :human_reviewed, :attacker_reachable] then :soft
70
+ in [:hard, :human_reviewed, :tool_vouched] then :soft
71
+ in [:weakened, :unreviewed, :attacker_reachable] then :soft
72
+ in [:weakened, :unreviewed, :tool_vouched] then :soft
73
+ in [:weakened, :human_reviewed, :attacker_reachable] then :soft
74
+ in [:weakened, :human_reviewed, :tool_vouched] then :soft
75
+ end
76
+ end
77
+
78
+ # Root-first path of Reports down to the most severe node in this subtree,
79
+ # so the caller can both attribute the finding and read its cell:
80
+ #
81
+ # path = report.worst_path
82
+ # path.map(&:label).join(' -> ') # => "pikuri-computer -> RESEARCHER"
83
+ # path.last.legs # the cell that decided the verdict
84
+ #
85
+ # Ties go to the node encountered first in pre-order — the parent over its
86
+ # own children, since a parent's trifecta is the more actionable finding.
87
+ #
88
+ # @return [Array<Report>] never empty; +[self]+ when nothing below is worse
89
+ def worst_path
90
+ best = [self]
91
+ children.each do |child|
92
+ candidate = [self, *child.worst_path]
93
+ best = candidate if SEVERITY.index(candidate.last.verdict) > SEVERITY.index(best.last.verdict)
94
+ end
95
+ best
96
+ end
97
+
98
+ # @return [Symbol] the most severe {#verdict} anywhere in this subtree
99
+ def tree_verdict = worst_path.last.verdict
100
+
101
+ # The human-readable map: one line per agent and per tool, indented by
102
+ # depth, with each entry's legs as tags.
103
+ #
104
+ # Trifecta: soft
105
+ #
106
+ # pikuri-computer [:private, :untrusted_hard, :egress_reviewed]
107
+ # mail_compose [:private, :egress_reviewed]
108
+ # calculator []
109
+ # RESEARCHER [gate: human_reviewed] [:untrusted_hard, :egress]
110
+ # fetch [:untrusted_hard, :egress]
111
+ #
112
+ # See book/trifecta-detector.md in this repo for ...
113
+ #
114
+ # An agent line carries its *folded* legs and a tool line its own, so
115
+ # propagation shows up as layout: the parent above holds egress that no
116
+ # tool of its own contributes. Plain text by design — a host wanting color
117
+ # or width walks this tree itself rather than asking for styled output.
118
+ #
119
+ # @return [String] newline-separated, no trailing newline
120
+ def render
121
+ [advisory, '', *render_lines].join("\n")
122
+ end
123
+
124
+ # The one-line conclusion: what was found, where, and the leg to break.
125
+ # Separate from {#render} because a status line wants this without the
126
+ # tree, and because it is the half that carries *meaning* — the map
127
+ # deliberately carries only facts.
128
+ #
129
+ # [TRIFECTA: HARD] pikuri-code — private(read, bash) + untrusted(read,
130
+ # bash) + egress(bash). An injection in what this agent reads can drive
131
+ # an exfiltration. Break a leg: sever egress (a network-off sandbox), or
132
+ # privilege-separate the step that reads untrusted content.
133
+ # See book/trifecta-detector.md in this repo.
134
+ #
135
+ # A clean verdict never says "no trifecta detected". This reasons about
136
+ # capability presence, so a quiet result is not a clean bill of health —
137
+ # it under-warns on a single tool that is all three internally, and on
138
+ # private data staged into a file by one tool and read by another.
139
+ # Reporting the *finding* names what would tip the wiring over and claims
140
+ # nothing it cannot support.
141
+ #
142
+ # @return [String]
143
+ def advisory
144
+ path = worst_path
145
+ where = path.map(&:label).join(' -> ')
146
+ legs = path.last.legs
147
+ return "Trifecta: #{where} — #{presence(legs)}." if tree_verdict == :silent
148
+
149
+ "[TRIFECTA: #{tree_verdict == :loud ? 'HARD' : 'soft'}] #{where} — " \
150
+ "#{attribution(path.last)}. An injection in what this agent reads can drive " \
151
+ "an exfiltration. Break a leg: #{advice(legs)}. #{FOOTER}"
152
+ end
153
+
154
+ protected
155
+
156
+ # @param depth [Integer] indentation level
157
+ # @return [Array<String>]
158
+ def render_lines(depth = 0)
159
+ indent = ' ' * depth
160
+ gate = channel_egress ? " [gate: #{channel_egress}]" : ''
161
+ lines = ["#{indent}#{label}#{gate} #{legs.to_tags.inspect}"]
162
+ tool_legs.each { |name, tool| lines << "#{indent} #{name} #{tool.to_tags.inspect}" }
163
+ children.each { |child| lines.concat(child.render_lines(depth + 1)) }
164
+ lines
165
+ end
166
+
167
+ private
168
+
169
+ # @param legs [Pikuri::Tool::TrifectaLegs]
170
+ # @return [String] e.g. +"2 of 3 legs (private, untrusted); no egress leg found"+
171
+ def presence(legs)
172
+ held = { 'private' => legs.private, 'untrusted' => legs.untrusted != :none,
173
+ 'egress' => legs.egress? }
174
+ "#{held.count { |_, v| v }} of 3 legs (#{held.select { |_, v| v }.keys.join(', ')}); " \
175
+ "no #{held.reject { |_, v| v }.keys.join('/')} leg found"
176
+ end
177
+
178
+ # Which tool contributed each leg, so the line names something the reader
179
+ # can actually go and change.
180
+ #
181
+ # @param node [Report]
182
+ # @return [String]
183
+ def attribution(node)
184
+ %i[private untrusted egress].filter_map do |axis|
185
+ contributors = node.tool_legs.select { |_, l| leg_present?(l, axis) }.keys
186
+ "#{axis}(#{contributors.join(', ')})" unless contributors.empty?
187
+ end.join(' + ')
188
+ end
189
+
190
+ # @param legs [Pikuri::Tool::TrifectaLegs]
191
+ # @param axis [Symbol] +:private+, +:untrusted+ or +:egress+
192
+ # @return [Boolean]
193
+ def leg_present?(legs, axis)
194
+ case axis
195
+ in :private then legs.private
196
+ in :untrusted then legs.untrusted != :none
197
+ in :egress then legs.egress?
198
+ end
199
+ end
200
+
201
+ # The fix, per cell of the verdict grid — the soft cells want different
202
+ # answers, which is why this reads the legs rather than the verdict symbol.
203
+ #
204
+ # @param legs [Pikuri::Tool::TrifectaLegs]
205
+ # @return [String]
206
+ def advice(legs)
207
+ # A vouched destination is fragile in a way the other attenuations are
208
+ # not: it survives only while it is the *sole* egress leg, since the
209
+ # union hardens the whole node the moment one attacker-reachable tool
210
+ # joins it. Say so, rather than let the soft verdict read as settled.
211
+ if legs.egress_destination == :tool_vouched
212
+ return 'sever egress outright — bytes still leave, they merely reach a destination the ' \
213
+ 'attacker cannot read back, and one attacker-reachable tool re-hardens this node'
214
+ end
215
+
216
+ case [legs.untrusted, legs.egress_payload_review]
217
+ in [:hard, :unreviewed]
218
+ 'sever egress (a network-off sandbox), or privilege-separate the step that reads untrusted content'
219
+ in [:hard, _]
220
+ 'privilege-separate the step that reads untrusted content, or declare the workspace trusted ' \
221
+ 'if you vouch for every byte it can reach'
222
+ in [:weakened, :unreviewed]
223
+ 'sever egress — the untrusted leg is already attenuated by delegation'
224
+ in [:weakened, _]
225
+ 'both legs are already attenuated; narrow what counts as private, or drop a leg outright'
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ # The lethal-trifecta detector: does one agent simultaneously hold private
5
+ # data, exposure to attacker-influenceable content, and a way to send bytes
6
+ # out? Any two legs are fine; all three is the live wire, because an injection
7
+ # hidden in the untrusted content can drive the exfiltration.
8
+ #
9
+ # Build a {Node} tree — the agent, its tools, its sub-agents — and walk it:
10
+ #
11
+ # report = Pikuri::Trifecta.walk(node)
12
+ # report.tree_verdict # => :loud
13
+ # puts report.render # the map a bin script prints
14
+ #
15
+ # Nothing here reports itself. Core computes the picture and stops; surfacing
16
+ # it belongs to whoever has a screen ({Pikuri::Agent#trifecta} is the reader).
17
+ # A library writing to a log its embedder never asked for is noise, and a
18
+ # once-per-process guard would be worse than silence in a host that builds
19
+ # two differently-wired agents: it would report the first and stay quiet
20
+ # about the second, which reads as coverage.
21
+ #
22
+ # == This is a smoke alarm, not a firewall
23
+ #
24
+ # It reasons about *capability presence*, never data flow, so it both
25
+ # over-warns (an egress query too narrow to carry anything) and under-warns
26
+ # (one tool that is all three internally; private data staged into a file by
27
+ # one tool and read out by another). It can prove you hold the ingredients. It
28
+ # can prove neither that you are exploitable nor that you are safe — which is
29
+ # why nothing here ever blocks execution, and why the absence of a warning is
30
+ # never reported as an assurance.
31
+ #
32
+ # == Implementation details
33
+ #
34
+ # {.walk} is a pure function of the tree, so the whole rule set is testable
35
+ # from hand-built {Node}s with no agent, no LLM and no sandbox probe.
36
+ #
37
+ # Two operations move legs between nodes, and their asymmetry is the design:
38
+ # +max+ in *parallel* (legs meeting at one node, {Pikuri::Tool::TrifectaLegs#|})
39
+ # and +min+ in *series* (a leg squeezed through a delegation channel,
40
+ # {Pikuri::Tool::TrifectaLegs#cap_payload_review}). Severity is evaluated at every node
41
+ # rather than only at the root, and the tree's verdict is the max over all of
42
+ # them — which localizes a finding to the agent that actually holds it instead
43
+ # of smearing a sub-agent's danger across its parent.
44
+ module Trifecta
45
+ class << self
46
+ # Assemble the tree for a wired agent: its own tools, plus whatever each
47
+ # extension contributes ({Pikuri::Agent::Extension#trifecta_contribution}
48
+ # — legs no tool can declare, and a child per sub-agent persona).
49
+ #
50
+ # @param agent [Pikuri::Agent] a *wired* agent (after the bind sweep, so
51
+ # dynamically-added tools are present)
52
+ # @param label [String] name for the root node; defaults to the running
53
+ # program, which is what a host wants in a boot banner
54
+ # @return [Node]
55
+ def build(agent, label: File.basename($PROGRAM_NAME))
56
+ # The root node holds +tools+ ONLY. +sub_agent_tools+ are registered for
57
+ # personas and never reach the parent's LLM — crediting the parent with
58
+ # them reports the sub-agent quarantine as broken, which is the one
59
+ # false alarm guaranteed to get this advisory muted, since that
60
+ # quarantine is the defense the loudest wirings are built around.
61
+ #
62
+ # The *hook* gets both, because a persona resolves its +tool_names+
63
+ # against either list.
64
+ #
65
+ # A tool neither list places — registered on the sub-agent pool and
66
+ # named by no persona — therefore appears nowhere in the tree, and gets
67
+ # no line of its own. Deliberate: nothing here can call it, so it is the
68
+ # absence of a capability rather than one, and it can never explain a
69
+ # verdict. The reverse direction *is* a bug and fails loudly elsewhere —
70
+ # {Pikuri::SubAgent::Extension#configure} raises on a persona naming an
71
+ # unregistered tool. See +D_trifecta_map_orphans+.
72
+ contributions = agent.extensions.filter_map do |ext|
73
+ if ext.respond_to?(:trifecta_contribution)
74
+ ext.trifecta_contribution(agent.tools + agent.sub_agent_tools)
75
+ end
76
+ end
77
+
78
+ tool_legs = agent.tools.to_h { |t| [t.name, t.trifecta_legs] }
79
+ contributions.each do |contribution|
80
+ next if contribution.legs == Tool::TrifectaLegs::NONE
81
+
82
+ previous = tool_legs[contribution.label] || Tool::TrifectaLegs::NONE
83
+ tool_legs[contribution.label] = previous | contribution.legs
84
+ end
85
+
86
+ Node.new(label: label, tool_legs: tool_legs,
87
+ children: contributions.flat_map(&:children))
88
+ end
89
+
90
+ # Fold a {Node} tree into a {Report} tree: each node's own tools unioned,
91
+ # then every sub-agent's legs propagated up.
92
+ #
93
+ # @param node [Node] root of the tree, usually the main agent
94
+ # @return [Report] mirrors the input tree, legs folded
95
+ def walk(node)
96
+ children = node.children.map { |child| walk(child) }
97
+ legs = node.tool_legs.values.reduce(Tool::TrifectaLegs::NONE) { |acc, tool| acc | tool }
98
+ legs = children.reduce(legs) { |acc, child| acc | propagated(child) }
99
+
100
+ Report.new(label: node.label, legs: legs, tool_legs: node.tool_legs,
101
+ children: children, channel_egress: node.channel_egress)
102
+ end
103
+
104
+ private
105
+
106
+ # What +child+ contributes to its parent: untrusted attenuated by the
107
+ # boundary, egress capped by the gate on the delegation channel.
108
+ #
109
+ # The cap is skipped when the child holds private data of its own, and
110
+ # that precondition is load-bearing rather than a nicety. A child with
111
+ # file tools can be handed an innocuous approved task, read secrets
112
+ # *after* the gate, and egress a derivative of bytes no human ever saw —
113
+ # so the channel no longer bounds what can leave.
114
+ #
115
+ # @param child [Report]
116
+ # @return [Pikuri::Tool::TrifectaLegs]
117
+ def propagated(child)
118
+ contribution = child.legs.relabel_inbound
119
+ return contribution if child.legs.private
120
+
121
+ # A child with no recorded edge is treated as ungated: absent evidence
122
+ # of a gate is not evidence of one.
123
+ contribution.cap_payload_review(child.channel_egress || :unreviewed)
124
+ end
125
+ end
126
+ end
127
+ end
@@ -4,52 +4,39 @@ require 'digest'
4
4
  require 'fileutils'
5
5
 
6
6
  module Pikuri
7
- # On-disk cache for string-keyed text payloads. Used by the bundled tools
8
- # to avoid re-fetching the same page or re-issuing the same web-search
9
- # query within a TTL window: {Tool::WebScrape.visit} caches the rendered
10
- # Markdown for a URL, and {Tool::Search::Engines.search} caches the
11
- # rendered result list for a query (the query string itself acts as the
12
- # key — keys are SHA-256 hashed, so any opaque string works).
7
+ # On-disk cache for string-keyed text payloads. The bundled tools use it to
8
+ # avoid re-fetching within a TTL: {Tool::WebScrape.visit} caches a URL's
9
+ # rendered Markdown, {Tool::Search::Engines.search} caches a query's result
10
+ # list (keys are SHA-256 hashed, so any opaque string works).
13
11
  #
14
- # Each tool wires its own {UrlCache} instance against a dedicated
15
- # subdirectory under {ROOT_DIR}, so a +web_search+ query string and a
16
- # +web_scrape+ URL string can never collide on the same cache file. There
17
- # is no global default singleton — pass a fresh instance to whichever
18
- # code needs caching, or use {NULL} to disable caching entirely.
12
+ # Each tool wires its own instance against a dedicated subdir under {ROOT_DIR},
13
+ # so a +web_search+ query and a +web_scrape+ URL can't collide on one file.
14
+ # No global singleton — pass a fresh instance, or {NULL} to disable caching.
19
15
  #
20
- # One file per entry, named +<sha256>.txt+ under {#initialize}'s +dir+.
21
- # Freshness is tracked via the file's mtime; there is no sidecar metadata.
22
- # Stale entries are simply overwritten the next time {#fetch} is called
23
- # with the same key. To clear the cache, +rm -rf+ the directory.
16
+ # One file per entry (+<sha256>.txt+), freshness by mtime, no sidecar
17
+ # metadata; a stale entry is overwritten on the next {#fetch} with that key.
18
+ # +rm -rf+ the directory to clear it.
24
19
  #
25
- # Not thread-safe: if two callers race on the same cold key, both compute
26
- # and both write the same file. That is the intended tradeoff to keep this
27
- # under a few dozen lines — the worst-case cost is a duplicate fetch.
20
+ # Sharing: +P_shared_benign+ — an instance holds only its TTL and directory,
21
+ # and every tool that owns one holds it in a class-level constant, so all
22
+ # agents (and other pikuri processes) hit the same files. If two callers race
23
+ # on the same cold key, both compute and both write the same bytes — the
24
+ # accepted tradeoff for keeping this a few dozen lines (worst case: a
25
+ # duplicate fetch). The cross-agent flow is deliberate: a public URL's content
26
+ # is nobody's private context.
28
27
  class UrlCache
29
- # Root directory under which per-tool cache subdirectories live.
30
- # Follows the XDG Base Directory spec: +$XDG_CACHE_HOME/pikuri/url_cache+
31
- # if the env var is set to a non-empty value, else
32
- # +~/.cache/pikuri/url_cache+. Each tool picks its own subdir
33
- # (e.g. +"#{ROOT_DIR}/web_scrape"+) so keys from different tools cannot
34
- # collide. The directory is created lazily on first cache write; pikuri
35
- # does not pre-create it.
28
+ # Root dir for per-tool cache subdirs, per XDG:
29
+ # +$XDG_CACHE_HOME/pikuri/url_cache+ or +~/.cache/pikuri/url_cache+. Each
30
+ # tool picks its own subdir (+"#{ROOT_DIR}/web_scrape"+) so keys can't
31
+ # collide. Created lazily on first write.
36
32
  # @return [String]
37
- ROOT_DIR = begin
38
- xdg = ENV['XDG_CACHE_HOME']
39
- cache_home = xdg && !xdg.empty? ? xdg : File.join(Dir.home, '.cache')
40
- File.join(cache_home, 'pikuri', 'url_cache')
41
- end.freeze
33
+ ROOT_DIR = Paths.cache.join('url_cache').to_path
42
34
 
43
- # Default freshness window: 2 hours, in seconds.
44
- #
45
- # Long enough to cover a single interactive session — revisiting
46
- # a scraped page or re-running a similar search within the same
47
- # working window hits the cache. Short enough that resuming the
48
- # next day doesn't serve stale news, docs, or search results.
49
- # Reference points: opencode keeps no cache, the +pi-web-fetch+
50
- # community extension uses 15 minutes, +pi-web-search+ uses 5;
51
- # 2 hours sits comfortably above the "single follow-up" window
52
- # those numbers are aimed at without holding content across days.
35
+ # Default freshness window: 2 hours. Long enough to cover an interactive
36
+ # session (revisiting a page or re-running a search within the working
37
+ # window hits the cache), short enough that resuming the next day doesn't
38
+ # serve stale results. (opencode caches nothing; +pi-web-fetch+ uses 15 min,
39
+ # +pi-web-search+ 5.)
53
40
  # @return [Integer]
54
41
  DEFAULT_TTL = 2 * 60 * 60
55
42
 
@@ -62,13 +49,11 @@ module Pikuri
62
49
  @dir = dir
63
50
  end
64
51
 
65
- # Return the cached payload for +url+ if a fresh entry exists, otherwise
66
- # yield to compute it, persist the result, and return it.
67
- #
68
- # The block is only invoked on a miss. If the block raises, no file is
69
- # written — errors are not cached.
52
+ # Return the cached payload for +url+ if fresh, else yield to compute it,
53
+ # persist, and return. The block runs only on a miss; if it raises, no file
54
+ # is written (errors aren't cached).
70
55
  #
71
- # @param url [String] cache key; a URL or any opaque string identifier
56
+ # @param url [String] cache key; a URL or any opaque string
72
57
  # @yieldreturn [String] payload to store and return on a miss
73
58
  # @return [String] cached or freshly-computed payload
74
59
  def fetch(url)
@@ -94,9 +79,8 @@ module Pikuri
94
79
  File.join(@dir, "#{Digest::SHA256.hexdigest(url)}.txt")
95
80
  end
96
81
 
97
- # Null cache: a drop-in replacement that always misses and never
98
- # persists. Use this in tests (or anywhere else you want caching off)
99
- # without giving up the {UrlCache#fetch} contract.
82
+ # Null cache: always misses, never persists — a drop-in for tests (or
83
+ # anywhere caching should be off) keeping the {UrlCache#fetch} contract.
100
84
  NULL = Object.new
101
85
  # Singleton +fetch+ on {NULL} that mirrors {UrlCache#fetch}'s shape:
102
86
  # ignores +_key+, always yields, never persists.
@@ -6,5 +6,5 @@ module Pikuri
6
6
  # additions to the public surface (+Pikuri::Tool+ / +Pikuri::Agent+ /
7
7
  # listeners / bundled tools), major for breaking changes to that
8
8
  # surface or to the +bin/pikuri-*+ CLIs.
9
- VERSION = '0.0.7'
9
+ VERSION = '0.1.0'
10
10
  end