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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7b962310fa6c20cae48446f97272e6e9865c22d3700128366e4f7082a23ea501
4
+ data.tar.gz: e0d0b77825dbc716dc3cbb113867a72e4a56395877a00e9763936aeb1709ee2b
5
+ SHA512:
6
+ metadata.gz: 55f1d13f4f9000286e4f0556cf7e29b33ee24135f3d222c2963eadbf6d73d472cacfcbd893d3d16c27e44c84416effc66c823d54f7513b3f635b2bed10cbfdec
7
+ data.tar.gz: 3595db2fd68deb8e89767554e6b08945be88dcd17fbd29d288b1c954606825b92619c36edc87dc76d15f7ac5b73ce36ae2fb1bdc2aa4579d7b0152671a26e5ba
data/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # pikuri-os
2
+
3
+ Offline Linux/OS helper agent + OS-integration tools for the
4
+ [pikuri](https://codeberg.org/mvysny/pikuri) AI-assistant toolkit.
5
+
6
+ `pikuri-os` is the home of `bin/pikuri-os`: a single, local,
7
+ **network-severed** agent that understands and operates the host OS —
8
+ answer questions about the machine, find and open files, read logs and
9
+ explain errors, advise on configuration. It is the federation's
10
+ privacy-first `@os` member shipped as a standalone single agent (walked
11
+ through in [the guide's OS-assistant chapter](../book/os-assistant.md); see
12
+ [`ideas/pikuri-os.md`](../ideas/pikuri-os.md) for the design
13
+ origin and the work still deferred).
14
+
15
+ Posture (the load-bearing parts):
16
+
17
+ - **Egress severed at the kernel.** Bash runs in a full-filesystem,
18
+ net-isolated sandbox (the inverse of the coding agent's networked
19
+ one), so the trifecta's exfiltration leg is gone by construction —
20
+ not by a prompt.
21
+ - **Every mutation confirmed.** `Bash`/`Write`/`Edit` route through the
22
+ confirmer; a conservative deterministic auto-confirmer waves through
23
+ only provably-read-only commands.
24
+ - **Advise, don't execute, anything privileged or networked.** Anything
25
+ needing `sudo` or the network is handed to the user to run.
26
+ - **Memory is a single `MACHINE.md`**, not mem0 — a `CLAUDE.md` for this
27
+ host, appended to the system prompt at boot and editable through the
28
+ confirmed write path.
29
+
30
+ **Opt-in web research.** `--with-internet` enables web access, but
31
+ quarantined: `web_search` / `web_scrape` / `fetch` live inside an isolated
32
+ `RESEARCHER` sub-agent that has the web tools but **no filesystem** —
33
+ reached via the `agent` tool. The main agent holds **no direct web
34
+ tool**, so it has zero unconfirmed egress; you approve each research task
35
+ before it runs (the human, not the model, authors what leaves the
36
+ machine), and the researcher — having no private data — can at worst leak
37
+ that approved task plus the public pages it read. This is a *separate*
38
+ network boundary from the bash sandbox, which **stays kernel-severed
39
+ regardless**. Off by default; the agent stays networkless until you pass
40
+ the flag. See [`ideas/more-confirmers.md`](../ideas/more-confirmers.md)
41
+ for the harder pieces still deferred (editable task prompts, a
42
+ reject-reason channel).
43
+
44
+ **Target platform:** Ubuntu 26.04 LTS or newer, GNOME desktop.
45
+
46
+ Depends on `pikuri-core` (Agent + Tool framework), `pikuri-workspace`
47
+ (file tools + confirmer), and `pikuri-code` (Bash + sandbox).
48
+ Deliberately **not** `pikuri-mcp` / `pikuri-memory` / `pikuri-vectordb`
49
+ — severing those keeps the no-egress, audit-first posture honest.
50
+
51
+ > Status: skeleton. The OS-specific surface (localsearch-backed
52
+ > content search, `MACHINE.md` memory, boot grounding) and the binary
53
+ > land incrementally.
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Os
5
+ # One-call wiring for the offline OS-helper agent. Adding this single
6
+ # extension to an +Agent.new+ block gives the complete surface — the
7
+ # five workspace file tools, +bash+ (net-severable, with the
8
+ # passive-command detector), the file-index tools, and the machine grounding +
9
+ # +MACHINE.md+ memory folded into the system prompt.
10
+ #
11
+ # A composite: +configure+ adds {Pikuri::Workspace::Extension} and
12
+ # {Pikuri::Code::Extension} as sub-extensions, then layers on the
13
+ # OS-specific tools and prompt sections.
14
+ #
15
+ # == Confirmation policy
16
+ #
17
+ # The caller passes the *human* confirmer. The file tools get it via
18
+ # {Pikuri::Workspace::Extension} with +confirm_all_writes: true+: since
19
+ # this agent roams the full disk (no containment), *every* writable
20
+ # Write/Edit confirms with a diff and an unwritable target
21
+ # short-circuits to "ask the user to sudo" (see
22
+ # {Pikuri::Workspace::WriteGate}, book/os-assistant.md). +Bash+ gets the same
23
+ # confirmer plus a {Pikuri::Code::Bash::PassiveCommandDetector}, so
24
+ # provably passive commands skip the prompt and everything else
25
+ # reaches the human — bash mutations and file-tool writes both always
26
+ # confirm.
27
+ #
28
+ # The detector is built +allow_git: true+, so passive git verbs skip
29
+ # the prompt too (no per-repo trust prompt). Safe here *only because*
30
+ # {Pikuri::Code::Bash::GIT_HARDENING} neutralizes the hostile
31
+ # +core.fsmonitor+ vector that fires on a plain +git status+; the
32
+ # accepted residual is diff-driver execution on an attacker-written
33
+ # +.git/config+ (confined to non-clone-delivered repos). Full detail:
34
+ # {Pikuri::Code::Bash::PassiveCommandDetector}'s +allow_git:+ yardoc.
35
+ #
36
+ # == Sandbox
37
+ #
38
+ # The +sandbox+ is the caller's choice (default {Pikuri::Code::Bash::Sandbox::NONE}).
39
+ # The OS-helper binary passes {Pikuri::Code::Bash::Sandbox::FullFsNoNet}
40
+ # for the net-severed, full-filesystem posture; that decision (and its
41
+ # fail-loud probe) lives in the binary, so a differently-hosted client
42
+ # can isolate differently.
43
+ #
44
+ # == Degradation
45
+ #
46
+ # The file-index tools need a usable index backend. The extension wires
47
+ # them on the first of {FILE_INDEX_BACKENDS} that is
48
+ # {LocalSearch.available? usable} — {LocalSearch} (GNOME, zero-setup),
49
+ # then {Recoll} (DE-independent, but only with a built index). When none
50
+ # is usable it logs and omits the tools — the agent still constructs and
51
+ # works (bash + file tools).
52
+ class Extension
53
+ include Pikuri::Agent::Extension
54
+
55
+ LOGGER = Pikuri.logger_for('Os::Extension')
56
+
57
+ # @return [Array<Module>] file-index backends in preference order; the
58
+ # first one whose +#available?+ is true backs the fileindex tools.
59
+ FILE_INDEX_BACKENDS = [LocalSearch, Recoll].freeze
60
+
61
+ # @param filesystem [Pikuri::Workspace::Filesystem] the host fs view
62
+ # for the file tools and bash (typically
63
+ # {Pikuri::Workspace::Filesystem::AllowAll} for full-host access).
64
+ # @param confirmer [Pikuri::Workspace::Confirmer] the *human*
65
+ # confirmer; Bash is auto-gated on top of it (see "Confirmation
66
+ # policy").
67
+ # @param sandbox [Pikuri::Code::Bash::Sandbox] bash subprocess
68
+ # isolation; defaults to {Pikuri::Code::Bash::Sandbox::NONE}.
69
+ # @param read_only [Pikuri::Workspace::ReadOnly, nil] the shared
70
+ # read-only flag; when present it is threaded into both
71
+ # {Pikuri::Workspace::Extension} (gates Write/Edit) and
72
+ # {Pikuri::Code::Extension} (wires plan mode), so one instance
73
+ # drives the whole posture. +nil+ leaves plan mode unavailable.
74
+ # @param memory [MachineMemory] the MACHINE.md reader.
75
+ # @param system_info [SystemInfo] the boot-time machine facts.
76
+ # @param file_index_backends [Array<Module>] file-index backends in
77
+ # preference order; defaults to {FILE_INDEX_BACKENDS}. The first
78
+ # usable one backs the fileindex tools; if none is usable they are
79
+ # omitted.
80
+ def initialize(filesystem:, confirmer:,
81
+ sandbox: Pikuri::Code::Bash::Sandbox::NONE, read_only: nil,
82
+ memory: MachineMemory.new, system_info: SystemInfo.new,
83
+ file_index_backends: FILE_INDEX_BACKENDS)
84
+ @filesystem = filesystem
85
+ @confirmer = confirmer
86
+ @sandbox = sandbox
87
+ @read_only = read_only
88
+ @memory = memory
89
+ @system_info = system_info
90
+ @file_index_backends = file_index_backends
91
+ end
92
+
93
+ # @param c [Pikuri::Agent::Configurator]
94
+ # @return [void]
95
+ def configure(c)
96
+ c.add_extension(Pikuri::Workspace::Extension.new(
97
+ filesystem: @filesystem, confirmer: @confirmer, read_only: @read_only,
98
+ confirm_all_writes: true
99
+ ))
100
+ c.add_extension(Pikuri::Code::Extension.new(
101
+ filesystem: @filesystem,
102
+ confirmer: @confirmer,
103
+ passive_detector: Pikuri::Code::Bash::PassiveCommandDetector.new(allow_git: true),
104
+ sandbox: @sandbox,
105
+ read_only: @read_only
106
+ ))
107
+
108
+ backend = @file_index_backends.find(&:available?)
109
+ if backend
110
+ c.add_tool FileindexSearch.new(backend: backend)
111
+ c.add_tool FileindexRead.new(backend: backend)
112
+ else
113
+ tried = @file_index_backends.map(&:label).join(' / ')
114
+ LOGGER.warn("no usable file index (tried #{tried}); fileindex_search / " \
115
+ 'fileindex_read disabled on this host.')
116
+ end
117
+
118
+ nil
119
+ end
120
+
121
+ # Two grounding sections: the machine facts and the MACHINE.md notes
122
+ # (or an affordance pointing at where to record them). System-info is
123
+ # memoized at its source (re-pull on clear is free); the memory
124
+ # section is read **fresh**, so MACHINE.md edits surface on a
125
+ # "/clear".
126
+ #
127
+ # @return [Array<String>]
128
+ def system_prompt_snippets
129
+ [@system_info.prompt_section, @memory.prompt_section || empty_memory_affordance]
130
+ end
131
+
132
+ private
133
+
134
+ # System-prompt line shown when MACHINE.md doesn't exist yet, so the
135
+ # agent still knows where to record durable facts.
136
+ #
137
+ # @return [String]
138
+ def empty_memory_affordance
139
+ <<~SECTION.chomp
140
+ # Notes about this machine
141
+
142
+ No durable notes recorded yet. You can record lasting facts about this
143
+ computer's setup by creating and editing #{@memory.path}.
144
+ SECTION
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+
5
+ module Pikuri
6
+ module Os
7
+ # The +fileindex_read+ tool — read a file's full text *as stored in
8
+ # the desktop index*, line-paged like {Pikuri::Workspace::Read}. The
9
+ # companion to {FileindexSearch} (which locates + previews one match),
10
+ # sharing its swappable +backend:+ ({LocalSearch} / {Recoll}).
11
+ #
12
+ # It reads the index, not the file: the index already holds every
13
+ # indexed file's extracted plain text — PDFs/office docs included — so
14
+ # this returns them in full with no extractor dependency. The cost is
15
+ # that it's the *indexed snapshot* (can lag very recent edits, covers
16
+ # only files the index knows); for a live or system text file
17
+ # (+/etc+, +/var/log+, a just-written file), read it directly instead.
18
+ #
19
+ # +offset+ / +limit+ page exactly as in {Pikuri::Workspace::Read},
20
+ # windowed through {Pikuri::Extractor.extract_paged} (a +StringIO+ over
21
+ # the stored text) with the same +cat -n+ + trailer, caps, and markers.
22
+ #
23
+ # Sharing: +P_stateless+ — the backend is a stateless module over a
24
+ # read-only index, so one instance serves any number of agents.
25
+ class FileindexRead < Pikuri::Tool
26
+ # @return [Integer] default +limit+ (lines per call); the shared
27
+ # page default, so it matches +read+.
28
+ DEFAULT_LIMIT = Pikuri::Extractor::PAGE_DEFAULT_LIMIT
29
+
30
+ # @return [String] human-readable byte cap for the trailer.
31
+ MAX_BYTES_LABEL = "#{Pikuri::Extractor::PAGE_MAX_BYTES / 1024} KB"
32
+
33
+ # @return [String] opencode-shape description (summary + Usage).
34
+ DESCRIPTION = <<~DESC
35
+ Read a file's full text from the desktop search index, with line numbers.
36
+
37
+ Usage:
38
+ - The companion to fileindex_search: after it locates a file, read the file in full here.
39
+ - Reads the indexed text, so PDFs and office documents come back as their extracted text — no special handling needed.
40
+ - This is the indexed snapshot: it can lag very recent edits, and only files the index knows are available. For a live or system text file (/etc, /var/log, a just-written file), use the read tool instead.
41
+ - Output is line-numbered `cat -n` style; use `offset` and `limit` to page. When the response ends in `Use offset=N to continue`, call again with that offset.
42
+ DESC
43
+
44
+ # @param backend [Module] a file-index backend ({LocalSearch} /
45
+ # {Recoll}); only +#read_text+ and +#check_binaries!+ are used here.
46
+ # @raise [RuntimeError] if the backend's binary isn't on +PATH+.
47
+ # @return [FileindexRead]
48
+ def initialize(backend:)
49
+ @backend = backend
50
+ backend.check_binaries!
51
+ super(
52
+ name: 'fileindex_read',
53
+ description: DESCRIPTION,
54
+ parameters: Parameters.build { |p|
55
+ p.required_string :path,
56
+ 'Absolute path to the file (as returned by ' \
57
+ 'fileindex_search), e.g. "/home/u/Documents/report.pdf".'
58
+ p.optional_integer :offset,
59
+ 'Line number to start reading from (1-indexed). ' \
60
+ 'Defaults to 1, e.g. 200.'
61
+ p.optional_integer :limit,
62
+ 'Maximum number of lines to read. Defaults to ' \
63
+ "#{DEFAULT_LIMIT}, e.g. 500."
64
+ },
65
+ execute: lambda { |path:, offset: 1, limit: DEFAULT_LIMIT|
66
+ FileindexRead.read(path: path, offset: offset, limit: limit, backend: backend)
67
+ },
68
+ # As {FileindexSearch}: whole-machine reach, so both inbound legs are fixed.
69
+ trifecta_legs: Pikuri::Tool::TrifectaLegs.new(private: true, untrusted: :hard, egress_payload_review: :no_egress)
70
+ )
71
+ end
72
+
73
+ # @param path [String]
74
+ # @param backend [Module] the file-index backend to read from
75
+ # @param offset [Integer] 1-indexed start line
76
+ # @param limit [Integer] max lines
77
+ # @return [String] the cat-n windowed text, or +"Error: ..."+
78
+ def self.read(path:, backend:, offset: 1, limit: DEFAULT_LIMIT)
79
+ return "Error: offset must be >= 1, got #{offset}" if offset < 1
80
+ return "Error: limit must be >= 1, got #{limit}" if limit < 1
81
+
82
+ text = backend.read_text(path)
83
+ if text.nil?
84
+ return "Error: #{path} is not in the index (only indexed folders are " \
85
+ 'available; PDFs and office docs are included). For a live or ' \
86
+ 'system text file, use the read tool.'
87
+ end
88
+
89
+ page = Pikuri::Extractor.extract_paged(
90
+ StringIO.new(text), content_type: 'text/plain', offset: offset, limit: limit
91
+ )
92
+ render_page(page)
93
+ end
94
+
95
+ # Render a {Pikuri::Extractor::Page} as +cat -n+ + a paging trailer,
96
+ # mirroring {Pikuri::Workspace::Read}'s formatting.
97
+ #
98
+ # @param page [Pikuri::Extractor::Page]
99
+ # @return [String]
100
+ def self.render_page(page)
101
+ return '(No indexed text for this file.)' if page.total_lines.zero?
102
+
103
+ if page.lines.empty?
104
+ return "Error: offset #{page.start_line} is beyond end of file " \
105
+ "(#{page.total_lines} lines total)"
106
+ end
107
+
108
+ last = page.start_line + page.lines.length - 1
109
+ body = page.lines.each_with_index
110
+ .map { |line, i| format("%6d\t%s", i + page.start_line, line) }
111
+ .join("\n")
112
+
113
+ trailer =
114
+ if page.byte_capped
115
+ "(Output capped at #{MAX_BYTES_LABEL}. Showing lines #{page.start_line}-#{last}. " \
116
+ "Use offset=#{last + 1} to continue.)"
117
+ elsif page.more
118
+ total = page.total_lines ? " of #{page.total_lines}" : ''
119
+ "(Showing lines #{page.start_line}-#{last}#{total}. Use offset=#{last + 1} to continue.)"
120
+ else
121
+ "(End of file - total #{page.total_lines} lines)"
122
+ end
123
+
124
+ "#{body}\n\n#{trailer}"
125
+ end
126
+ private_class_method :render_page
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Os
5
+ # The +fileindex_search+ tool — a fast, cheap keyword lookup over a
6
+ # desktop file index, returning matching paths each with the *single*
7
+ # snippet the index provides. A {Pikuri::Tool} in the
8
+ # {Pikuri::Workspace::Search::Grep} shape (shells out through the backend,
9
+ # fails loud at construction if the binary is missing).
10
+ #
11
+ # The +backend:+ is a stateless module satisfying the file-index duck
12
+ # type (see {#initialize}) — {LocalSearch} or {Recoll}, whichever
13
+ # {Extension} finds usable. Backend-specific text (the blind-spot note,
14
+ # the error label) is read from the backend, so a new index arm ships
15
+ # its own without touching the tool.
16
+ #
17
+ # It's a locator, not a window: the index reports at most one match per
18
+ # file, so the tool previews one match each and tells the LLM to read
19
+ # the file for full content / other matches. It sees inside PDFs and
20
+ # office docs (indexed at extract time), and being an index lookup is
21
+ # cheap to run repeatedly.
22
+ #
23
+ # Sharing: +P_stateless+ — the backend is a stateless module and each call
24
+ # shells out to its own query, so one instance serves any number of agents.
25
+ class FileindexSearch < Pikuri::Tool
26
+ # @return [Integer] default / max matching files returned.
27
+ DEFAULT_LIMIT = 10
28
+ MAX_LIMIT = 25
29
+
30
+ # @return [Integer] hard byte cap on combined output (matches
31
+ # {Pikuri::Workspace::Search::Grep::MAX_BYTES}).
32
+ MAX_BYTES = 50 * 1024
33
+
34
+ # @return [String] human-readable form of {MAX_BYTES}.
35
+ MAX_BYTES_LABEL = "#{MAX_BYTES / 1024} KB"
36
+
37
+ # @return [String] opencode-shape description (summary + Usage),
38
+ # backend-neutral. The *runtime* tool description is this constant
39
+ # plus the backend's +#limitations+, appended at construction (see
40
+ # {#initialize}) — and it is that appended text, not this constant,
41
+ # that states *which folders the index actually covers* and what it
42
+ # misses (recoll lists its +topdirs+; localsearch notes it ranks
43
+ # across the whole home). So keep coverage claims out of here; they
44
+ # belong with the backend that knows them.
45
+ DESCRIPTION = <<~DESC
46
+ Find files whose contents contain your words or phrase, using the desktop search index.
47
+
48
+ Usage:
49
+ - Searches inside PDFs, Word/ODT/spreadsheet documents and plain text — reach for this when the answer may live in a file's contents.
50
+ - Keyword match (and word stems); type the actual words that would appear in the file.
51
+ - The index returns at most ONE match per file and may be slightly stale — this is a locator, not the full story. To read a file's full, current text, read it (see fileindex_read).
52
+ - Cheap: it queries a prebuilt index rather than walking the filesystem, so it barely touches the disk of the machine the user is on — fine to run many times to narrow down.
53
+ - Only indexed folders are covered; system paths like /etc or /var are not — search those with a text/regex tool instead.
54
+ - Output is truncated to #{MAX_BYTES_LABEL}; narrow the query if it ends in a truncation marker.
55
+ DESC
56
+
57
+ # @param backend [Module] a file-index backend ({LocalSearch} /
58
+ # {Recoll}) — must respond to +#search+, +#read_text+,
59
+ # +#check_binaries!+, +#limitations+, +#label+ and define a
60
+ # +CommandError+.
61
+ # @raise [RuntimeError] if the backend's binary isn't on +PATH+.
62
+ # @return [FileindexSearch]
63
+ def initialize(backend:)
64
+ @backend = backend
65
+ backend.check_binaries!
66
+ super(
67
+ name: 'fileindex_search',
68
+ description: "#{DESCRIPTION}\n#{backend.limitations}",
69
+ parameters: Parameters.build { |p|
70
+ p.required_string :query,
71
+ 'Words or a phrase to find in file contents, ' \
72
+ 'e.g. "tax return 2024".'
73
+ p.optional_integer :limit,
74
+ "Max number of matching files to return " \
75
+ "(default #{DEFAULT_LIMIT}, max #{MAX_LIMIT}), e.g. 10."
76
+ },
77
+ execute: lambda { |query:, limit: DEFAULT_LIMIT|
78
+ FileindexSearch.search(query: query, limit: limit, backend: backend)
79
+ },
80
+ # Private and hard untrusted, both fixed rather than derived: this searches
81
+ # the *whole machine's* file index, which is the AllowAll case by
82
+ # construction — a reachable set nobody can vouch for, holding whatever the
83
+ # user keeps on disk.
84
+ trifecta_legs: Pikuri::Tool::TrifectaLegs.new(private: true, untrusted: :hard, egress_payload_review: :no_egress)
85
+ )
86
+ end
87
+
88
+ # @param query [String]
89
+ # @param limit [Integer]
90
+ # @param backend [Module] the file-index backend to query
91
+ # @return [String] formatted hits + disclaimer, a no-match message,
92
+ # or +"Error: ..."+
93
+ def self.search(query:, backend:, limit: DEFAULT_LIMIT)
94
+ q = query.to_s.strip
95
+ return 'Error: empty query.' if q.empty?
96
+
97
+ limit = clamp(limit, 1, MAX_LIMIT)
98
+ hits = backend.search(query: q, limit: limit)
99
+ return no_match_message(q) if hits.empty?
100
+
101
+ content, marker = head_truncate(hits.map { |hit| render_hit(hit) }.join("\n"))
102
+ [content.chomp, '', disclaimer(hits.size) + marker].join("\n")
103
+ rescue backend::CommandError => e
104
+ # Cap the error too — a raw subprocess error returned verbatim is
105
+ # the shape that overflows the model's context; share the success
106
+ # path's ceiling.
107
+ content, marker = head_truncate(e.message)
108
+ "Error: #{backend.label}: #{content}#{marker}"
109
+ end
110
+
111
+ # @return [String] +"<path>"+ or +"<path>\n <snippet>"+
112
+ def self.render_hit(hit)
113
+ hit[:snippet] && !hit[:snippet].empty? ? "#{hit[:path]}\n #{hit[:snippet]}" : hit[:path]
114
+ end
115
+ private_class_method :render_hit
116
+
117
+ # @return [String]
118
+ def self.disclaimer(count)
119
+ "Found #{count} #{count == 1 ? 'file' : 'files'} (one match shown per file; " \
120
+ 'the index may be slightly stale). Read a file to see its full text and any other matches.'
121
+ end
122
+ private_class_method :disclaimer
123
+
124
+ # @return [String]
125
+ def self.no_match_message(query)
126
+ "No files found matching '#{query}'. (Searches your indexed folders, " \
127
+ 'not system paths; the index may also be disabled or still building.)'
128
+ end
129
+ private_class_method :no_match_message
130
+
131
+ # Head-truncate to {MAX_BYTES} at the last newline boundary.
132
+ #
133
+ # @return [Array(String, String)] +[content, marker]+ (marker empty
134
+ # when no truncation)
135
+ def self.head_truncate(raw)
136
+ total = raw.bytesize
137
+ return [raw, ''] if total <= MAX_BYTES
138
+
139
+ head = raw.byteslice(0, MAX_BYTES)
140
+ last_nl = head.rindex("\n")
141
+ head = head.byteslice(0, last_nl) if last_nl
142
+ omitted = total - head.bytesize
143
+ [head, "\n\n... [#{omitted} bytes omitted; total was #{total} bytes; narrow the query] ..."]
144
+ end
145
+ private_class_method :head_truncate
146
+
147
+ # @return [Integer] +value+ clamped to +[lo, hi]+ (lo on bad input)
148
+ def self.clamp(value, lo, hi)
149
+ Integer(value).clamp(lo, hi)
150
+ rescue ArgumentError, TypeError
151
+ lo
152
+ end
153
+ private_class_method :clamp
154
+ end
155
+ end
156
+ end