Class: Pikuri::Thunderbird::MailSearch

Inherits:
Pikuri::Tool
  • Object
show all
Defined in:
lib/pikuri/thunderbird/mail_search.rb

Overview

The thunderbird_mail_search tool — ranked, deduped search over the local Thunderbird mailbox via Gloda. Inbound-only (no egress). Its capability pair is thunderbird_mail_read, which fetches a hit's full body by the id: handle this tool returns (parent-document retrieval, like corpus search→read).

Sharing: P_shared_locked — no state of its own, and the Gloda backend it queries locks; see that class's == Sharing.

Constant Summary collapse

DEFAULT_LIMIT =

Returns default / max hits returned. The cap protects the host's context budget (a snippet-laden page is dear on a small local model); past it, ranking is moot and narrowing is the right move — which is what header steers the model toward.

Returns:

  • (Integer)

    default / max hits returned. The cap protects the host's context budget (a snippet-laden page is dear on a small local model); past it, ranking is moot and narrowing is the right move — which is what header steers the model toward.

15
MAX_LIMIT =
50
DESCRIPTION =

Returns opencode-shape description.

Returns:

  • (String)

    opencode-shape description.

<<~DESC
  Search the user's local Thunderbird mail (subject, body, sender, recipients, attachment names) and return ranked matches.

  Usage:
  - Free-text query and/or exact filters (sender, recipient, subject, date range, folder) — both optional; supply at least one.
  - Multiple words match broadly (any of them), and the best-matching messages rank first — so extra words widen the net and improve ranking rather than requiring all to appear.
  - With no query, the filters alone list matches newest-first — e.g. the latest mail from a sender is from="alice@acme.com" with no query.
  - Reads Thunderbird's own local index — local folders only; it never connects to any mail server.
  - Returns the best matches with a snippet and a stable id; read a match in full with thunderbird_mail_read.
  - Spam/Junk/Trash are not searched.
DESC

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(backend:) ⇒ MailSearch

Parameters:



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/pikuri/thunderbird/mail_search.rb', line 36

def initialize(backend:)
  @backend = backend
  super(
    name: 'thunderbird_mail_search',
    description: DESCRIPTION,
    parameters: Parameters.build { |p|
      p.optional_string :query, 'Words or a phrase to find, e.g. "invoice from acme". Omit to list by the filters alone (newest first).'
      p.optional_string :from, 'Only mail whose sender contains this, e.g. "alice@acme.com".'
      p.optional_string :to, 'Only mail whose recipients contain this, e.g. "me@example.com".'
      p.optional_string :subject, 'Only mail whose subject contains this, e.g. "receipt".'
      p.optional_string :after, 'Only mail on/after this date, e.g. "2026-01-01".'
      p.optional_string :before, 'Only mail on/before this date, e.g. "2026-06-30".'
      p.optional_string :folder, 'Restrict to one folder by its exact name, e.g. "INBOX".'
      p.optional_integer :limit, "Max matches (default #{DEFAULT_LIMIT}, max #{MAX_LIMIT}), e.g. 10."
    },
    execute: lambda { |query: nil, from: nil, to: nil, subject: nil,
                       after: nil, before: nil, folder: nil, limit: DEFAULT_LIMIT|
      MailSearch.run_search(backend: @backend, query:, from:, to:, subject:,
                            after:, before:, folder:, limit:)
    },
    trifecta_legs: Pikuri::Thunderbird::INBOUND_LEGS
  )
end

Class Method Details

.run_search(backend:, query:, from:, to:, subject:, after:, before:, folder:, limit:) ⇒ String

Returns formatted hits, a no-match line, or "Error: …".

Returns:

  • (String)

    formatted hits, a no-match line, or "Error: …".



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/pikuri/thunderbird/mail_search.rb', line 61

def self.run_search(backend:, query:, from:, to:, subject:, after:, before:, folder:, limit:)
  # Refuse a match-everything call: with no query and no filter this
  # would dump the whole mailbox newest-first. Every real request has at
  # least one criterion — steer the model to name one.
  if [query, from, to, subject, after, before, folder].all? { |v| v.to_s.strip.empty? }
    return 'Error: give at least one of query, from, to, subject, after, before, or folder to search for.'
  end

  limit = limit.to_i.clamp(1, MAX_LIMIT)
  after_t = DateHelpers.parse_after(after)
  before_t = DateHelpers.parse_before(before)
  # Fetch one past the cap so a full page is distinguishable from an
  # exhausted mailbox: getting limit+1 back means more exist, and the
  # header must say so — an LLM reads a full page as "that's everything"
  # and gives a silently incomplete answer. (Heavy Gmail label-dup can
  # false-negative if the (limit+1)th distinct match falls outside the
  # backend's over-fetch window; rare, accepted.)
  hits = backend.search(query:, limit: limit + 1, from:, to:, subject:,
                        after: after_t, before: before_t, folder:)
  if hits.empty?
    return query.to_s.strip.empty? ? 'No mail matched those filters.' \
                                   : "No matching mail found for #{query.inspect}."
  end

  has_more = hits.size > limit
  hits = hits.first(limit)
  [header(shown: hits.size, has_more:), *hits.map { |h| render(h) }].join("\n\n")
rescue ArgumentError => e
  "Error: bad date filter (#{e.message})."
end