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