pikuri-os 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +53 -0
- data/lib/pikuri/os/extension.rb +148 -0
- data/lib/pikuri/os/fileindex_read.rb +129 -0
- data/lib/pikuri/os/fileindex_search.rb +156 -0
- data/lib/pikuri/os/local_search.rb +210 -0
- data/lib/pikuri/os/machine_memory.rb +109 -0
- data/lib/pikuri/os/recoll.rb +307 -0
- data/lib/pikuri/os/system_info.rb +247 -0
- data/lib/pikuri-os.rb +29 -0
- data/prompts/os-assistant.txt +13 -0
- data/prompts/os-network-online.txt +7 -0
- metadata +141 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Pikuri
|
|
6
|
+
module Os
|
|
7
|
+
# Backend wrapper over GNOME's +localsearch+ CLI (the desktop file
|
|
8
|
+
# index, 2024 rename of tracker3 / tracker-miners). Not a
|
|
9
|
+
# {Pikuri::Tool} — the shared, stateless seam behind
|
|
10
|
+
# +fileindex_search+ / +fileindex_read+, so localsearch-specific
|
|
11
|
+
# knowledge (argv, parsing, json-ld extraction, binary probe) lives in
|
|
12
|
+
# one place.
|
|
13
|
+
#
|
|
14
|
+
# +localsearch search+ returns at most *one* match snippet per file
|
|
15
|
+
# from a possibly-stale index. {.search} returns exactly those
|
|
16
|
+
# one-per-file hits and the tools tell the LLM to read the file for the
|
|
17
|
+
# rest — enumerating every per-file hit would need a stemmer (the index
|
|
18
|
+
# is stemmed, "mouse" matches "mice"), deliberately out of scope.
|
|
19
|
+
#
|
|
20
|
+
# PDFs and office docs come for free: the index extracted their text at
|
|
21
|
+
# index time, so {.read_text} returns it via +info -c+ with no
|
|
22
|
+
# extractor dependency here.
|
|
23
|
+
module LocalSearch
|
|
24
|
+
# @return [String] the localsearch CLI.
|
|
25
|
+
BINARY = 'localsearch'
|
|
26
|
+
|
|
27
|
+
# @return [String] short human name for this backend, used by the
|
|
28
|
+
# fileindex tools when prefixing an +"Error: ..."+ observation.
|
|
29
|
+
def self.label = 'localsearch'
|
|
30
|
+
|
|
31
|
+
# Uniform accessor mirroring {Recoll.limitations}. A constant here
|
|
32
|
+
# (unlike recoll's runtime method) because this backend's blind spots
|
|
33
|
+
# are the same on every host.
|
|
34
|
+
#
|
|
35
|
+
# @return [String]
|
|
36
|
+
def self.limitations = LIMITATIONS
|
|
37
|
+
|
|
38
|
+
# Plain-English note of this backend's *structural* blind spots,
|
|
39
|
+
# appended to the fileindex tools' LLM description so the model knows
|
|
40
|
+
# when to fall back to a filesystem text/regex search. Lives on the
|
|
41
|
+
# backend (a different index ships its own). All three are confirmed
|
|
42
|
+
# against GNOME localsearch (measured; see +pikuri-os/DESIGN.md+).
|
|
43
|
+
#
|
|
44
|
+
# @return [String]
|
|
45
|
+
LIMITATIONS = <<~LIMITS.chomp
|
|
46
|
+
Not covered by this index (fall back to a text/regex search over the files for these):
|
|
47
|
+
- Source code and other developer files are not content-indexed — only their names are. Search their text directly.
|
|
48
|
+
- Nothing inside a git checkout is indexed (any folder containing a .git). Search those files directly.
|
|
49
|
+
- No folder scoping: it ranks across your whole home at once, so a common word confined to one project may stay buried below the results shown. Prefer distinctive words, or search that folder's text directly.
|
|
50
|
+
LIMITS
|
|
51
|
+
|
|
52
|
+
# Raised when a localsearch invocation exits non-zero — a
|
|
53
|
+
# recoverable failure the calling tool renders as +"Error: ..."+.
|
|
54
|
+
class CommandError < StandardError; end
|
|
55
|
+
|
|
56
|
+
# @return [Regexp] one ANSI SGR escape (color/bold), stripped from
|
|
57
|
+
# snippets before they reach the LLM.
|
|
58
|
+
ANSI_SGR = /\e\[[0-9;]*m/
|
|
59
|
+
|
|
60
|
+
# Run +localsearch search -d --limit <limit> <query>+ and parse the
|
|
61
|
+
# results. +-d+ adds the (ignored) URN and, on the line after each
|
|
62
|
+
# path, a short snippet with the matched word highlighted; we strip
|
|
63
|
+
# the ANSI and keep the text.
|
|
64
|
+
#
|
|
65
|
+
# @param query [String]
|
|
66
|
+
# @param limit [Integer]
|
|
67
|
+
# @return [Array<Hash>] +[{ path: String, snippet: String|nil }, …]+,
|
|
68
|
+
# de-duplicated by path
|
|
69
|
+
# @raise [CommandError] if localsearch exits non-zero
|
|
70
|
+
def self.search(query:, limit:)
|
|
71
|
+
result = Pikuri::Subprocess.spawn(BINARY, 'search', '-d', '--limit', limit.to_s, query,
|
|
72
|
+
chdir: '/').wait
|
|
73
|
+
unless result.status.success?
|
|
74
|
+
stderr = result.output.strip
|
|
75
|
+
stderr = "exited #{result.status.exitstatus}" if stderr.empty?
|
|
76
|
+
raise CommandError, stderr
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
parse_search_output(result.output)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Parse +localsearch search -d+ output into hits. Each result is a
|
|
83
|
+
# +file://<uri> (urn:…)+ line followed by an indented snippet line;
|
|
84
|
+
# the path is percent-decoded and the snippet ANSI-stripped.
|
|
85
|
+
#
|
|
86
|
+
# @param raw [String]
|
|
87
|
+
# @return [Array<Hash>] +[{ path:, snippet: }, …]+ de-duped by path
|
|
88
|
+
def self.parse_search_output(raw)
|
|
89
|
+
hits = []
|
|
90
|
+
current = nil
|
|
91
|
+
raw.each_line do |line|
|
|
92
|
+
encoded = line[%r{\Afile://(\S+)}, 1]
|
|
93
|
+
if encoded
|
|
94
|
+
current = { path: decode_file_uri(encoded), snippet: nil }
|
|
95
|
+
hits << current
|
|
96
|
+
elsif current && current[:snippet].nil? && !line.strip.empty?
|
|
97
|
+
current[:snippet] = line.gsub(ANSI_SGR, '').strip
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
hits.uniq { |hit| hit[:path] }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Full stored plain text of an indexed file, via +localsearch info
|
|
104
|
+
# -c -o json-ld <path>+. Works for PDFs/office docs (extracted at
|
|
105
|
+
# index time). +nil+ when the path isn't in the index or has no
|
|
106
|
+
# stored text — the caller turns that into an LLM-facing message.
|
|
107
|
+
#
|
|
108
|
+
# @param path [String]
|
|
109
|
+
# @return [String, nil]
|
|
110
|
+
def self.read_text(path)
|
|
111
|
+
result = Pikuri::Subprocess.spawn(BINARY, 'info', '-c', '-o', 'json-ld', path,
|
|
112
|
+
chdir: '/').wait
|
|
113
|
+
return nil unless result.status.success?
|
|
114
|
+
|
|
115
|
+
text = extract_plain_text(result.output)
|
|
116
|
+
text && !text.strip.empty? ? text : nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Find +nie:plainTextContent+ anywhere in the json-ld document (it
|
|
120
|
+
# nests under +@graph+, a named graph). Lenient: if the raw bytes
|
|
121
|
+
# don't parse as JSON (a stray diagnostic prefix), retry from the
|
|
122
|
+
# first +{+.
|
|
123
|
+
#
|
|
124
|
+
# @param raw [String]
|
|
125
|
+
# @return [String, nil]
|
|
126
|
+
def self.extract_plain_text(raw)
|
|
127
|
+
data = parse_json_lenient(raw)
|
|
128
|
+
data && find_plain_text(data)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Verify localsearch is reachable; raise loudly otherwise.
|
|
132
|
+
#
|
|
133
|
+
# @raise [RuntimeError] if the binary is missing or unusable
|
|
134
|
+
def self.check_binaries!
|
|
135
|
+
result = Pikuri::Subprocess.spawn(BINARY, '--version', chdir: '/').wait
|
|
136
|
+
return if result.status.success?
|
|
137
|
+
|
|
138
|
+
raise install_hint
|
|
139
|
+
rescue Errno::ENOENT
|
|
140
|
+
raise install_hint
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Non-raising presence probe — lets a caller wire the file-index
|
|
144
|
+
# tools only when localsearch is installed and degrade gracefully
|
|
145
|
+
# otherwise, rather than failing the whole agent.
|
|
146
|
+
#
|
|
147
|
+
# @return [Boolean]
|
|
148
|
+
def self.available?
|
|
149
|
+
Pikuri::Subprocess.spawn(BINARY, '--version', chdir: '/').wait.status.success?
|
|
150
|
+
rescue Errno::ENOENT
|
|
151
|
+
false
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @param encoded [String] the tail of a +file://+ URI
|
|
155
|
+
# @return [String] percent-decoded path (+%XX+ only, UTF-8 tagged)
|
|
156
|
+
def self.decode_file_uri(encoded)
|
|
157
|
+
encoded.gsub(/%([0-9A-Fa-f]{2})/) { ::Regexp.last_match(1).hex.chr }
|
|
158
|
+
.force_encoding('UTF-8')
|
|
159
|
+
end
|
|
160
|
+
private_class_method :decode_file_uri
|
|
161
|
+
|
|
162
|
+
# @return [Object, nil]
|
|
163
|
+
def self.parse_json_lenient(raw)
|
|
164
|
+
JSON.parse(raw)
|
|
165
|
+
rescue JSON::ParserError
|
|
166
|
+
brace = raw.index('{')
|
|
167
|
+
return nil unless brace
|
|
168
|
+
|
|
169
|
+
begin
|
|
170
|
+
JSON.parse(raw[brace..])
|
|
171
|
+
rescue JSON::ParserError
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
private_class_method :parse_json_lenient
|
|
176
|
+
|
|
177
|
+
# @param node [Object]
|
|
178
|
+
# @return [String, nil] first String under a +nie:plainTextContent+
|
|
179
|
+
# key, depth-first
|
|
180
|
+
def self.find_plain_text(node)
|
|
181
|
+
case node
|
|
182
|
+
when Hash
|
|
183
|
+
node.each do |key, value|
|
|
184
|
+
return value if key == 'nie:plainTextContent' && value.is_a?(String)
|
|
185
|
+
|
|
186
|
+
found = find_plain_text(value)
|
|
187
|
+
return found if found
|
|
188
|
+
end
|
|
189
|
+
nil
|
|
190
|
+
when Array
|
|
191
|
+
node.each do |element|
|
|
192
|
+
found = find_plain_text(element)
|
|
193
|
+
return found if found
|
|
194
|
+
end
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
private_class_method :find_plain_text
|
|
199
|
+
|
|
200
|
+
# @return [String]
|
|
201
|
+
def self.install_hint
|
|
202
|
+
"fileindex_search / fileindex_read require '#{BINARY}' on PATH — GNOME's " \
|
|
203
|
+
"desktop file indexer (package 'localsearch', formerly 'tracker-miners'), " \
|
|
204
|
+
'which ships with the GNOME desktop on Ubuntu 26.04+. Install it, or wire ' \
|
|
205
|
+
'these tools only on hosts that have it.'
|
|
206
|
+
end
|
|
207
|
+
private_class_method :install_hint
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module Pikuri
|
|
6
|
+
module Os
|
|
7
|
+
# Durable, cross-conversation memory for the OS helper — a single
|
|
8
|
+
# user-owned +MACHINE.md+, the +CLAUDE.md+-for-this-host. This class is
|
|
9
|
+
# the *read* half (resolve the XDG path, load content, size-warn,
|
|
10
|
+
# render the prompt section); the *write* half is the ordinary
|
|
11
|
+
# (confirmed) +write+/+edit+ tools plus a prompt line pointing the agent
|
|
12
|
+
# at {.default_path}. Narrative in book/os-assistant.md.
|
|
13
|
+
#
|
|
14
|
+
# A markdown file, not mem0: this agent reads untrusted local content
|
|
15
|
+
# (logs, downloads), so auto-extracted memory could be poisoned. A file
|
|
16
|
+
# makes that risk *visible + confirmed + reversible* — every write is a
|
|
17
|
+
# plain-text diff through the confirmer, the whole memory is one
|
|
18
|
+
# readable file — and needs no vector store or embedder, fitting the
|
|
19
|
+
# "small enough to audit" posture.
|
|
20
|
+
#
|
|
21
|
+
# {#content} reads fresh on every call and {#prompt_section} is re-pulled
|
|
22
|
+
# on every {Pikuri::Agent#clear_conversation}, so a user's or agent's
|
|
23
|
+
# edit surfaces on the next "/clear" rather than freezing at boot. Read
|
|
24
|
+
# a handful of times (boot + clears), never per turn.
|
|
25
|
+
class MachineMemory
|
|
26
|
+
LOGGER = Pikuri.logger_for('MachineMemory')
|
|
27
|
+
|
|
28
|
+
# @return [String] the memory filename.
|
|
29
|
+
FILENAME = 'MACHINE.md'
|
|
30
|
+
|
|
31
|
+
# @return [Integer] soft size ceiling. On overshoot we log a warning to
|
|
32
|
+
# trim — mirrors the Claude CLI nudging an oversized CLAUDE.md.
|
|
33
|
+
# No hard cap, no eviction: the file is prepended to every prompt,
|
|
34
|
+
# so a visible nudge is enough.
|
|
35
|
+
OVERSIZE_BYTES = 10 * 1024
|
|
36
|
+
|
|
37
|
+
# @return [Pathname] the resolved memory file path.
|
|
38
|
+
attr_reader :path
|
|
39
|
+
|
|
40
|
+
# @param path [String, Pathname] memory file location; defaults to
|
|
41
|
+
# {.default_path}. Overridable for tests.
|
|
42
|
+
def initialize(path: self.class.default_path)
|
|
43
|
+
@path = Pathname.new(path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Host-scoped path under pikuri's XDG config root
|
|
47
|
+
# ({Pikuri::Paths.config} — +$XDG_CONFIG_HOME/pikuri+ or
|
|
48
|
+
# +~/.config/pikuri+). Not project-local: for this agent the machine
|
|
49
|
+
# *and the user's home* are the subject, so the notes span the whole
|
|
50
|
+
# host.
|
|
51
|
+
#
|
|
52
|
+
# @return [String]
|
|
53
|
+
def self.default_path
|
|
54
|
+
Pikuri::Paths.config.join(FILENAME).to_s
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Raw file content, or +''+ when the file doesn't exist. Read
|
|
58
|
+
# fresh on each call (not memoized) so a re-pull on conversation
|
|
59
|
+
# clear picks up edits; logs the size-guard warning on each read.
|
|
60
|
+
#
|
|
61
|
+
# @return [String]
|
|
62
|
+
def content
|
|
63
|
+
read_content
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Boolean] whether the content exceeds {OVERSIZE_BYTES}.
|
|
67
|
+
def oversized?
|
|
68
|
+
content.bytesize > OVERSIZE_BYTES
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The system-prompt section: the notes wrapped in a labeled block
|
|
72
|
+
# naming the file (so the agent knows where its memory lives and can
|
|
73
|
+
# update it), or +nil+ when there's nothing recorded yet — keeping
|
|
74
|
+
# the prompt lean until there's something to say.
|
|
75
|
+
#
|
|
76
|
+
# @return [String, nil]
|
|
77
|
+
def prompt_section
|
|
78
|
+
text = content.strip
|
|
79
|
+
return nil if text.empty?
|
|
80
|
+
|
|
81
|
+
<<~SECTION.chomp
|
|
82
|
+
# Notes about this machine
|
|
83
|
+
|
|
84
|
+
Durable, user-owned notes about this computer (kept in #{@path}). Treat
|
|
85
|
+
them as trusted context; edit the file to keep the notes current as you
|
|
86
|
+
learn about this computer's setup.
|
|
87
|
+
|
|
88
|
+
#{text}
|
|
89
|
+
SECTION
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# @return [String]
|
|
95
|
+
def read_content
|
|
96
|
+
return '' unless @path.file?
|
|
97
|
+
|
|
98
|
+
text = @path.read
|
|
99
|
+
if text.bytesize > OVERSIZE_BYTES
|
|
100
|
+
LOGGER.warn(
|
|
101
|
+
"#{@path} is #{text.bytesize / 1024} KB (over the #{OVERSIZE_BYTES / 1024} KB " \
|
|
102
|
+
'guideline); it is prepended to every prompt — consider trimming it.'
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
text
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
5
|
+
module Pikuri
|
|
6
|
+
module Os
|
|
7
|
+
# Backend wrapper over the +recoll+ full-text index, via its headless
|
|
8
|
+
# +recollq+ CLI (Xapian-backed). The DE-independent sibling of
|
|
9
|
+
# {LocalSearch}: same seam shape ({.search} / {.read_text} /
|
|
10
|
+
# {.available?} / {.check_binaries!}), interchangeable behind the
|
|
11
|
+
# +fileindex_search+ / +fileindex_read+ tools, and {Extension} wires
|
|
12
|
+
# whichever is +available?+ first.
|
|
13
|
+
#
|
|
14
|
+
# Recall on prose ties +localsearch+, so recoll is *not* a better
|
|
15
|
+
# engine — it earns its place where +localsearch+ is structurally blind
|
|
16
|
+
# (source trees, +.git+ checkouts, non-GNOME desktops). Its blind spot
|
|
17
|
+
# is the inverse: recoll indexes exactly the *folders* the user
|
|
18
|
+
# configured (+topdirs+), source code included — everything outside them
|
|
19
|
+
# is invisible. The measured survey behind the pick-one-per-host order
|
|
20
|
+
# is in +pikuri-os/DESIGN.md+.
|
|
21
|
+
#
|
|
22
|
+
# {.search} asks +recollq -F 'url abstract'+ for two base64-encoded
|
|
23
|
+
# fields per line (recoll's own recommended machine shape) so paths and
|
|
24
|
+
# snippets with spaces/brackets/newlines survive with no escaping
|
|
25
|
+
# guesswork. {.read_text} uses +recollq -d+, which dumps a document's
|
|
26
|
+
# stored/extracted text (PDFs/office docs included) after a header line.
|
|
27
|
+
module Recoll
|
|
28
|
+
# @return [String] the headless recoll query CLI.
|
|
29
|
+
BINARY = 'recollq'
|
|
30
|
+
|
|
31
|
+
# @return [String] the recoll indexer/config CLI (used only to locate
|
|
32
|
+
# the config; never invoked to *index* from here).
|
|
33
|
+
INDEXER = 'recollindex'
|
|
34
|
+
|
|
35
|
+
# @return [String] recoll config filename inside the config dir.
|
|
36
|
+
CONFIG_FILE = 'recoll.conf'
|
|
37
|
+
|
|
38
|
+
# recoll's compiled-in default when +topdirs+ is unset: the whole
|
|
39
|
+
# home directory (the sample config ships +topdirs = ~+ too).
|
|
40
|
+
#
|
|
41
|
+
# @return [String]
|
|
42
|
+
DEFAULT_TOPDIRS = '~'
|
|
43
|
+
|
|
44
|
+
# @return [String] the Xapian index directory name inside the config
|
|
45
|
+
# dir; its presence is what {.available?} treats as "usable".
|
|
46
|
+
INDEX_DIR = 'xapiandb'
|
|
47
|
+
|
|
48
|
+
# Raised when a recollq invocation exits non-zero — a recoverable
|
|
49
|
+
# failure the calling tool renders as +"Error: ..."+.
|
|
50
|
+
class CommandError < StandardError; end
|
|
51
|
+
|
|
52
|
+
# @return [String] short human name for this backend, used by the
|
|
53
|
+
# fileindex tools when prefixing an +"Error: ..."+ observation.
|
|
54
|
+
def self.label = 'recoll'
|
|
55
|
+
|
|
56
|
+
# Plain-English note of what this backend will *not* surface, appended
|
|
57
|
+
# to the fileindex tools' LLM description so the model knows when to
|
|
58
|
+
# fall back to a filesystem text/regex search. A *method*, not a
|
|
59
|
+
# constant like {LocalSearch::LIMITATIONS}, because the blind spot is
|
|
60
|
+
# host-specific: the user's +topdirs+, only readable at runtime
|
|
61
|
+
# ({.indexed_dirs}). Falls back to a generic caveat when the config
|
|
62
|
+
# can't be read.
|
|
63
|
+
#
|
|
64
|
+
# @return [String]
|
|
65
|
+
def self.limitations
|
|
66
|
+
dirs = indexed_dirs
|
|
67
|
+
return generic_limitation if dirs.empty?
|
|
68
|
+
|
|
69
|
+
listed = dirs.map { |dir| "- #{dir}" }.join("\n")
|
|
70
|
+
<<~LIMITS.chomp
|
|
71
|
+
Only files under these folders are indexed (anything elsewhere on disk is not in this index — search those files' text directly instead):
|
|
72
|
+
#{listed}
|
|
73
|
+
LIMITS
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Folders recoll is configured to index, read from +topdirs+ in the
|
|
77
|
+
# active config file. Resolution mirrors recoll itself: the config dir
|
|
78
|
+
# is +$RECOLL_CONFDIR+ or +~/.recoll+; +topdirs+ is a space-separated
|
|
79
|
+
# list (quoted entries may contain spaces, a trailing +\+ continues to
|
|
80
|
+
# the next line) living in the global section, i.e. above the first
|
|
81
|
+
# +[section]+ header. Entries are +~+- and +$VAR+-expanded to absolute
|
|
82
|
+
# paths. Defaults to the home dir ({DEFAULT_TOPDIRS}) when the config
|
|
83
|
+
# is absent or names no +topdirs+, matching recoll's own default.
|
|
84
|
+
#
|
|
85
|
+
# @return [Array<String>] absolute folder paths, or +[]+ if the config
|
|
86
|
+
# dir exists but the file is unreadable
|
|
87
|
+
def self.indexed_dirs
|
|
88
|
+
path = File.join(confdir, CONFIG_FILE)
|
|
89
|
+
raw = File.exist?(path) ? File.read(path) : nil
|
|
90
|
+
entries = raw ? parse_topdirs(raw) : [DEFAULT_TOPDIRS]
|
|
91
|
+
entries = [DEFAULT_TOPDIRS] if entries.empty?
|
|
92
|
+
entries.map { |entry| expand_entry(entry) }
|
|
93
|
+
rescue SystemCallError
|
|
94
|
+
[]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The recoll configuration directory recoll would use: +$RECOLL_CONFDIR+
|
|
98
|
+
# if set and non-empty, else +~/.recoll+.
|
|
99
|
+
#
|
|
100
|
+
# @return [String]
|
|
101
|
+
def self.confdir
|
|
102
|
+
env = ENV['RECOLL_CONFDIR']
|
|
103
|
+
env && !env.empty? ? File.expand_path(env) : File.expand_path('~/.recoll')
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Run +recollq -F 'url abstract' -n <limit>+ and parse the results.
|
|
107
|
+
#
|
|
108
|
+
# @param query [String] recoll query-language string
|
|
109
|
+
# @param limit [Integer]
|
|
110
|
+
# @return [Array<Hash>] +[{ path: String, snippet: String|nil }, …]+,
|
|
111
|
+
# de-duplicated by path
|
|
112
|
+
# @raise [CommandError] if recollq exits non-zero
|
|
113
|
+
def self.search(query:, limit:)
|
|
114
|
+
result = Pikuri::Subprocess.spawn(BINARY, '-F', 'url abstract', '-n', limit.to_s, query,
|
|
115
|
+
chdir: '/').wait
|
|
116
|
+
unless result.status.success?
|
|
117
|
+
stderr = result.output.strip
|
|
118
|
+
stderr = "exited #{result.status.exitstatus}" if stderr.empty?
|
|
119
|
+
raise CommandError, stderr
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
parse_search_output(result.output)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Parse +recollq -F 'url abstract'+ output into hits. Each data line is
|
|
126
|
+
# two base64 tokens (url, abstract); recollq's +"Recoll query: …"+ /
|
|
127
|
+
# +"N results"+ banner lines are skipped because their first token
|
|
128
|
+
# isn't valid base64 for a +file://+ URL.
|
|
129
|
+
#
|
|
130
|
+
# @param raw [String]
|
|
131
|
+
# @return [Array<Hash>] +[{ path:, snippet: }, …]+ de-duped by path
|
|
132
|
+
def self.parse_search_output(raw)
|
|
133
|
+
hits = []
|
|
134
|
+
raw.each_line do |line|
|
|
135
|
+
url_b64, abstract_b64 = line.split(/\s+/, 2)
|
|
136
|
+
url = decode_base64(url_b64)
|
|
137
|
+
next unless url&.start_with?('file://')
|
|
138
|
+
|
|
139
|
+
snippet = decode_base64(abstract_b64.to_s.strip)
|
|
140
|
+
snippet = snippet&.strip
|
|
141
|
+
hits << { path: url.delete_prefix('file://'), snippet: (snippet unless snippet.to_s.empty?) }
|
|
142
|
+
end
|
|
143
|
+
hits.uniq { |hit| hit[:path] }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Full stored/extracted text of an indexed file, via +recollq -d+
|
|
147
|
+
# scoped to the file's own name. Works for PDFs/office docs (extracted
|
|
148
|
+
# at index time). +nil+ when the path isn't in the index or has no
|
|
149
|
+
# stored text — the caller turns that into an LLM-facing message.
|
|
150
|
+
#
|
|
151
|
+
# @param path [String]
|
|
152
|
+
# @return [String, nil]
|
|
153
|
+
def self.read_text(path)
|
|
154
|
+
abs = File.expand_path(path)
|
|
155
|
+
result = Pikuri::Subprocess.spawn(BINARY, '-d', '-n', '50', "filename:#{File.basename(abs)}",
|
|
156
|
+
chdir: '/').wait
|
|
157
|
+
return nil unless result.status.success?
|
|
158
|
+
|
|
159
|
+
text = extract_dumped_text(result.output, abs)
|
|
160
|
+
text && !text.strip.empty? ? text : nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Pull the dumped body of the result whose header line matches +abs+
|
|
164
|
+
# out of +recollq -d+ output. Each result is a tab-delimited header
|
|
165
|
+
# line (+<mime>\t[<url>]\t[<title>]\t<n>\tbytes+) followed by its text,
|
|
166
|
+
# up to the next header line.
|
|
167
|
+
#
|
|
168
|
+
# @param raw [String]
|
|
169
|
+
# @param abs [String] absolute path to match against the header URL
|
|
170
|
+
# @return [String, nil]
|
|
171
|
+
def self.extract_dumped_text(raw, abs)
|
|
172
|
+
want = "file://#{abs}"
|
|
173
|
+
body = []
|
|
174
|
+
capturing = false
|
|
175
|
+
raw.each_line do |line|
|
|
176
|
+
url = line[%r{\t\[(file://[^\]]*)\]\t}, 1]
|
|
177
|
+
if url
|
|
178
|
+
break if capturing
|
|
179
|
+
|
|
180
|
+
capturing = (url == want)
|
|
181
|
+
elsif capturing
|
|
182
|
+
body << line
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
capturing || !body.empty? ? body.join.chomp : nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Verify recollq is reachable; raise loudly otherwise. Presence is
|
|
189
|
+
# judged by whether the binary *ran* (no +Errno::ENOENT+), not by exit
|
|
190
|
+
# status: +recollq -h+ exits non-zero by design, so a zero-exit probe
|
|
191
|
+
# would false-negative on a perfectly good install.
|
|
192
|
+
#
|
|
193
|
+
# @raise [RuntimeError] if the binary is missing
|
|
194
|
+
def self.check_binaries!
|
|
195
|
+
Pikuri::Subprocess.spawn(BINARY, '-h', chdir: '/').wait
|
|
196
|
+
rescue Errno::ENOENT
|
|
197
|
+
raise install_hint
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Non-raising *usability* probe — for callers that select a backend
|
|
201
|
+
# among several and want the one that can actually serve searches.
|
|
202
|
+
# Stricter than {.check_binaries!}: it requires both the +recollq+
|
|
203
|
+
# binary *and* a built index ({INDEX_DIR} under {.confdir}), because
|
|
204
|
+
# an installed-but-never-indexed recoll errors ("Xapian index open
|
|
205
|
+
# error") on every query — so it must not win backend selection over
|
|
206
|
+
# a working alternative. See {.check_binaries!} for why exit status is
|
|
207
|
+
# ignored for the binary half.
|
|
208
|
+
#
|
|
209
|
+
# @return [Boolean]
|
|
210
|
+
def self.available?
|
|
211
|
+
return false unless File.directory?(File.join(confdir, INDEX_DIR))
|
|
212
|
+
|
|
213
|
+
Pikuri::Subprocess.spawn(BINARY, '-h', chdir: '/').wait
|
|
214
|
+
true
|
|
215
|
+
rescue Errno::ENOENT
|
|
216
|
+
false
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Join backslash-continued lines, then read the +topdirs+ value from
|
|
220
|
+
# the global section (everything before the first +[section]+ header).
|
|
221
|
+
# The last assignment wins (ConfSimple semantics).
|
|
222
|
+
#
|
|
223
|
+
# @param raw [String]
|
|
224
|
+
# @return [Array<String>] raw (unexpanded) topdirs entries
|
|
225
|
+
def self.parse_topdirs(raw)
|
|
226
|
+
value = nil
|
|
227
|
+
joined_lines(raw).each do |line|
|
|
228
|
+
stripped = line.strip
|
|
229
|
+
break if stripped.start_with?('[') # first [section] ends the global scope
|
|
230
|
+
next if stripped.empty? || stripped.start_with?('#')
|
|
231
|
+
|
|
232
|
+
key, _, rest = stripped.partition('=')
|
|
233
|
+
value = rest if key.strip == 'topdirs'
|
|
234
|
+
end
|
|
235
|
+
value ? tokenize(value) : []
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# @param raw [String]
|
|
239
|
+
# @return [Array<String>] logical lines with trailing-backslash
|
|
240
|
+
# continuations folded into one
|
|
241
|
+
def self.joined_lines(raw)
|
|
242
|
+
lines = []
|
|
243
|
+
buffer = +''
|
|
244
|
+
raw.each_line(chomp: true) do |line|
|
|
245
|
+
if line.end_with?('\\')
|
|
246
|
+
buffer << line[0..-2] << ' '
|
|
247
|
+
else
|
|
248
|
+
lines << (buffer + line)
|
|
249
|
+
buffer = +''
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
lines << buffer unless buffer.empty?
|
|
253
|
+
lines
|
|
254
|
+
end
|
|
255
|
+
private_class_method :joined_lines
|
|
256
|
+
|
|
257
|
+
# Split a topdirs value into entries, honouring +"double quotes"+
|
|
258
|
+
# around paths that contain spaces.
|
|
259
|
+
#
|
|
260
|
+
# @param value [String]
|
|
261
|
+
# @return [Array<String>]
|
|
262
|
+
def self.tokenize(value)
|
|
263
|
+
value.scan(/"([^"]*)"|(\S+)/).map { |quoted, bare| quoted || bare }
|
|
264
|
+
end
|
|
265
|
+
private_class_method :tokenize
|
|
266
|
+
|
|
267
|
+
# Expand +$VAR+ / +${VAR}+ then +~+ to an absolute path; a relative
|
|
268
|
+
# entry resolves against the home dir (recoll's own base).
|
|
269
|
+
#
|
|
270
|
+
# @param entry [String]
|
|
271
|
+
# @return [String]
|
|
272
|
+
def self.expand_entry(entry)
|
|
273
|
+
env_expanded = entry.gsub(/\$\{(\w+)\}|\$(\w+)/) { ENV[::Regexp.last_match(1) || ::Regexp.last_match(2)] || '' }
|
|
274
|
+
File.expand_path(env_expanded, Dir.home)
|
|
275
|
+
end
|
|
276
|
+
private_class_method :expand_entry
|
|
277
|
+
|
|
278
|
+
# @param token [String, nil]
|
|
279
|
+
# @return [String, nil] decoded bytes tagged UTF-8, or +nil+ if +token+
|
|
280
|
+
# is blank or not valid strict base64
|
|
281
|
+
def self.decode_base64(token)
|
|
282
|
+
return nil if token.nil? || token.empty?
|
|
283
|
+
|
|
284
|
+
Base64.strict_decode64(token).force_encoding('UTF-8')
|
|
285
|
+
rescue ArgumentError
|
|
286
|
+
nil
|
|
287
|
+
end
|
|
288
|
+
private_class_method :decode_base64
|
|
289
|
+
|
|
290
|
+
# @return [String]
|
|
291
|
+
def self.generic_limitation
|
|
292
|
+
'Only folders configured in recoll (topdirs) are indexed; files ' \
|
|
293
|
+
"outside them are not found — search those files' text directly instead."
|
|
294
|
+
end
|
|
295
|
+
private_class_method :generic_limitation
|
|
296
|
+
|
|
297
|
+
# @return [String]
|
|
298
|
+
def self.install_hint
|
|
299
|
+
"fileindex_search / fileindex_read require '#{BINARY}' on PATH — the " \
|
|
300
|
+
"recoll full-text indexer's headless query CLI (package 'recoll'). " \
|
|
301
|
+
'Install it and build an index (recollindex), or wire these tools ' \
|
|
302
|
+
'only on hosts that have it.'
|
|
303
|
+
end
|
|
304
|
+
private_class_method :install_hint
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|