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.
@@ -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