abbu 0.8.1 → 0.18.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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +129 -2
  3. data/bin/abbu +158 -6
  4. data/bin/abbu-mcp +36 -0
  5. data/docs/ABBU.md +28 -0
  6. data/docs/ALTERNATE_CALENDAR_EVIDENCE.md +82 -0
  7. data/docs/CHANGELOG.md +98 -0
  8. data/docs/CONTRIBUTING.md +3 -0
  9. data/docs/FUZZY_MATCHING.md +72 -0
  10. data/docs/HISTORY_EVIDENCE.md +98 -0
  11. data/docs/ICALENDAR.md +101 -0
  12. data/docs/MACHINE_JSON.md +128 -0
  13. data/docs/MCP.md +148 -0
  14. data/docs/MERGE_PLANS.md +78 -0
  15. data/docs/MY_CARD_EVIDENCE.md +92 -0
  16. data/docs/PORTABLE_SQLITE.md +142 -0
  17. data/docs/RELEASING.md +76 -68
  18. data/docs/SNAPSHOT_DIFF.md +74 -0
  19. data/docs/SNAPSHOT_HISTORY.md +93 -0
  20. data/docs/STREAMING.md +73 -0
  21. data/docs/TODO.md +4 -4
  22. data/docs/WRITER_DECISION.md +90 -0
  23. data/lib/abbu/archive.rb +14 -0
  24. data/lib/abbu/exporters/csv_exporter.rb +7 -0
  25. data/lib/abbu/exporters/icalendar_encoding.rb +35 -0
  26. data/lib/abbu/exporters/icalendar_event.rb +61 -0
  27. data/lib/abbu/exporters/icalendar_exporter.rb +86 -0
  28. data/lib/abbu/exporters/json_exporter.rb +2 -2
  29. data/lib/abbu/exporters/jsonl_exporter.rb +23 -0
  30. data/lib/abbu/exporters/sqlite_exporter.rb +85 -0
  31. data/lib/abbu/exporters/sqlite_rows.rb +113 -0
  32. data/lib/abbu/exporters/sqlite_schema.rb +76 -0
  33. data/lib/abbu/exporters/vcard_exporter.rb +10 -11
  34. data/lib/abbu/exporters/vcard_photo.rb +50 -0
  35. data/lib/abbu/history_tracker.rb +96 -0
  36. data/lib/abbu/live_store.rb +11 -0
  37. data/lib/abbu/machine_command.rb +159 -0
  38. data/lib/abbu/machine_output.rb +52 -0
  39. data/lib/abbu/mcp/read_session.rb +57 -0
  40. data/lib/abbu/mcp/server.rb +100 -0
  41. data/lib/abbu/mcp.rb +11 -0
  42. data/lib/abbu/merge_plan.rb +132 -0
  43. data/lib/abbu/merge_preview.rb +35 -0
  44. data/lib/abbu/merge_values.rb +26 -0
  45. data/lib/abbu/parsers/plist_parser.rb +9 -0
  46. data/lib/abbu/parsers/sqlite_parser.rb +25 -0
  47. data/lib/abbu/snapshot_diff.rb +125 -0
  48. data/lib/abbu/snapshot_history.rb +66 -0
  49. data/lib/abbu/utils/fuzzy_matcher.rb +116 -0
  50. data/lib/abbu/version.rb +1 -1
  51. data/lib/abbu.rb +9 -0
  52. data/sig/fuzzy_matcher.rbs +22 -0
  53. data/sig/icalendar_exporter.rbs +13 -0
  54. data/sig/machine_output.rbs +23 -0
  55. data/sig/mcp.rbs +19 -0
  56. data/sig/merge_plan.rbs +16 -0
  57. data/sig/snapshot_diff.rbs +8 -0
  58. data/sig/snapshot_history.rbs +10 -0
  59. data/sig/sqlite_exporter.rbs +10 -0
  60. data/sig/streaming.rbs +27 -0
  61. data/sig/vcard_exporter.rbs +1 -1
  62. metadata +46 -2
@@ -0,0 +1,76 @@
1
+ # lib/abbu/exporters/sqlite_schema.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Abbu
5
+ module Exporters
6
+ # Fixed ABBU-owned identifiers; source strings never become SQL identifiers.
7
+ module SqliteSchema
8
+ VERSION = 1
9
+ CONTACT_FIELDS = %i[first_name middle_name last_name nickname prefix suffix company job_title department
10
+ maiden_name phonetic_first_name phonetic_middle_name phonetic_last_name phonetic_company
11
+ pronouns ringtone texttone verification_code image_uri image_path].freeze
12
+ COLLECTIONS = {
13
+ emails: %i[address label raw_label], phones: %i[number label raw_label],
14
+ addresses: %i[street city state zip country label raw_label], urls: %i[url label raw_label],
15
+ related_names: %i[name label raw_label], social_profiles: %i[service username],
16
+ instant_messages: %i[address service label raw_label]
17
+ }.transform_values(&:freeze).freeze
18
+ DATE_FIELDS = %i[birthday anniversary lunar_birthday dates].freeze
19
+
20
+ module_function
21
+
22
+ def create(db)
23
+ db.execute('PRAGMA foreign_keys = ON')
24
+ db.execute("PRAGMA user_version = #{VERSION}")
25
+ db.execute('CREATE TABLE metadata (schema_version INTEGER NOT NULL, generator_version TEXT NOT NULL)')
26
+ db.execute('CREATE TABLE sources (id INTEGER PRIMARY KEY, path TEXT, relative_path TEXT, kind TEXT, ' \
27
+ 'identifier TEXT, evidence_json TEXT NOT NULL)')
28
+ create_contacts(db)
29
+ create_collections(db)
30
+ create_scalar_collections(db)
31
+ create_dates(db)
32
+ create_groups(db)
33
+ end
34
+
35
+ def create_contacts(db)
36
+ fields = CONTACT_FIELDS.map { |name| "#{name} TEXT" }.join(', ')
37
+ db.execute('CREATE TABLE contacts (id INTEGER PRIMARY KEY, source_id INTEGER REFERENCES sources(id), ' \
38
+ "#{fields}, " \
39
+ 'created_at TEXT, modified_at TEXT, evidence_json TEXT NOT NULL)')
40
+ db.execute('CREATE INDEX contacts_source ON contacts(source_id)')
41
+ end
42
+
43
+ def create_collections(db)
44
+ COLLECTIONS.each do |table, columns|
45
+ fields = columns.map { |name| "#{name} TEXT" }.join(', ')
46
+ db.execute("CREATE TABLE #{table} (contact_id INTEGER NOT NULL REFERENCES contacts(id), " \
47
+ "position INTEGER NOT NULL, #{fields}, evidence_json TEXT NOT NULL, " \
48
+ 'PRIMARY KEY(contact_id, position))')
49
+ end
50
+ end
51
+
52
+ def create_scalar_collections(db)
53
+ %w[notes group_labels].each do |table|
54
+ db.execute("CREATE TABLE #{table} (contact_id INTEGER NOT NULL REFERENCES contacts(id), " \
55
+ 'position INTEGER NOT NULL, value TEXT, evidence_json TEXT NOT NULL, ' \
56
+ 'PRIMARY KEY(contact_id, position))')
57
+ end
58
+ end
59
+
60
+ def create_dates(db)
61
+ db.execute('CREATE TABLE dates (contact_id INTEGER NOT NULL REFERENCES contacts(id), field TEXT NOT NULL, ' \
62
+ 'position INTEGER NOT NULL, year INTEGER, month INTEGER, day INTEGER, label TEXT, raw_label TEXT, ' \
63
+ 'evidence_json TEXT NOT NULL, PRIMARY KEY(contact_id, field, position))')
64
+ end
65
+
66
+ def create_groups(db)
67
+ db.execute('CREATE TABLE groups (id INTEGER PRIMARY KEY, source_id INTEGER REFERENCES sources(id), ' \
68
+ 'record_id INTEGER, name TEXT, evidence_json TEXT NOT NULL)')
69
+ db.execute('CREATE TABLE group_memberships (contact_id INTEGER NOT NULL REFERENCES contacts(id), ' \
70
+ 'position INTEGER NOT NULL, group_id INTEGER NOT NULL REFERENCES groups(id), ' \
71
+ 'PRIMARY KEY(contact_id, position))')
72
+ db.execute('CREATE INDEX memberships_group ON group_memberships(group_id)')
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,15 +1,15 @@
1
1
  # lib/abbu/exporters/vcard_exporter.rb
2
2
  # frozen_string_literal: true
3
3
 
4
- require 'uri'
5
-
6
4
  require_relative 'vcard_document'
5
+ require_relative 'vcard_photo'
7
6
 
8
7
  module Abbu
9
8
  module Exporters
10
9
  class VcardExporter
11
- def initialize(contacts)
10
+ def initialize(contacts, photo_mode: :uri)
12
11
  @contacts = contacts
12
+ @photo = VcardPhoto.new(photo_mode)
13
13
  end
14
14
 
15
15
  def to_file(path)
@@ -20,6 +20,11 @@ module Abbu
20
20
  print generate
21
21
  end
22
22
 
23
+ def write_to(io)
24
+ @contacts.each { |contact| io.write(vcard_for(contact)) }
25
+ nil
26
+ end
27
+
23
28
  private
24
29
 
25
30
  def generate
@@ -81,14 +86,8 @@ module Abbu
81
86
  end
82
87
 
83
88
  def append_photo(lines, contact)
84
- return unless contact.image_path
85
-
86
- lines << "PHOTO;VALUE=URI:#{file_uri(contact.image_path)}"
87
- end
88
-
89
- def file_uri(path)
90
- escaped_path = URI::DEFAULT_PARSER.escape(path.to_s)
91
- URI::Generic.build(scheme: 'file', path: escaped_path)
89
+ property = @photo.property(contact)
90
+ lines << property if property
92
91
  end
93
92
 
94
93
  def append_notes(lines, contact)
@@ -0,0 +1,50 @@
1
+ # lib/abbu/exporters/vcard_photo.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'uri'
5
+
6
+ module Abbu
7
+ module Exporters
8
+ # Serialization only: never resolves Apple image identifiers or converts bytes.
9
+ class VcardPhoto
10
+ def initialize(mode)
11
+ raise ArgumentError, 'photo_mode must be :uri or :embedded' unless %i[uri embedded].include?(mode)
12
+
13
+ @mode = mode
14
+ end
15
+
16
+ def property(contact)
17
+ path = contact.image_path
18
+ return embedded(contact) if @mode == :embedded
19
+ return unless path
20
+
21
+ escaped = URI::DEFAULT_PARSER.escape(path.to_s)
22
+ "PHOTO;VALUE=URI:#{URI::Generic.build(scheme: 'file', path: escaped)}"
23
+ end
24
+
25
+ private
26
+
27
+ def embedded(contact)
28
+ return unless contact.image_path || contact.image_uri
29
+
30
+ path = contact.image_path
31
+ raise ArgumentError, 'embedded photo is missing' unless path && File.file?(path)
32
+
33
+ bytes = File.binread(path)
34
+ type = image_type(bytes)
35
+ raise ArgumentError, 'embedded photo has unsupported content (HEIC is not supported)' unless type
36
+
37
+ "PHOTO;ENCODING=b;TYPE=#{type}:#{[bytes].pack('m0')}"
38
+ rescue IOError, SystemCallError
39
+ raise ArgumentError, 'embedded photo is unreadable'
40
+ end
41
+
42
+ def image_type(bytes)
43
+ return 'JPEG' if bytes.start_with?("\xFF\xD8\xFF".b)
44
+ return 'PNG' if bytes.start_with?("\x89PNG\r\n\x1A\n".b)
45
+
46
+ 'GIF' if bytes.start_with?('GIF87a', 'GIF89a')
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,96 @@
1
+ # lib/abbu/history_tracker.rb
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'snapshot_diff'
5
+
6
+ module Abbu
7
+ class HistoryTracker
8
+ def initialize(retention:, max_pairs:, max_contacts:)
9
+ @retention = retention
10
+ @max_pairs = max_pairs
11
+ @max_contacts = max_contacts
12
+ @pool = []
13
+ @next_id = 0
14
+ end
15
+
16
+ def advance(contacts, snapshot)
17
+ raise ArgumentError, 'snapshot exceeds max_contacts' if contacts.length > @max_contacts
18
+
19
+ diff = SnapshotDiff.new(@pool.map { |entry| entry[:contact] }, contacts, max_pairs: @max_pairs).to_h
20
+ current, observations = observations_for(diff, contacts, snapshot)
21
+ absent = absent_for(diff, snapshot)
22
+ ambiguity = ambiguity_for(diff)
23
+ retain(diff, current, snapshot)
24
+ { schema_version: 1, snapshot: snapshot, observations: observations.freeze,
25
+ absent: absent.freeze, ambiguous: ambiguity.freeze }.freeze
26
+ end
27
+
28
+ private
29
+
30
+ def ambiguity_for(diff)
31
+ diff[:ambiguous].map do |pair|
32
+ pair.merge(before_timeline_id: @pool.fetch(pair[:before_index])[:timeline_id]).freeze
33
+ end
34
+ end
35
+
36
+ def observations_for(diff, contacts, snapshot)
37
+ lookups = record_lookups(diff)
38
+ @current = []
39
+ observations = contacts.each_with_index.map do |contact, index|
40
+ observe_contact(contact, index, snapshot, lookups)
41
+ end
42
+ [@current, observations]
43
+ end
44
+
45
+ def record_lookups(diff)
46
+ { pairs: (diff[:changed] + diff[:unchanged]).to_h { |pair| [pair[:after_index], pair] },
47
+ added: diff[:added].to_h { |item| [item[:index], item[:contact]] },
48
+ uncertain: diff[:ambiguous].to_h { |pair| [pair[:after_index], pair[:after]] } }
49
+ end
50
+
51
+ def observe_contact(contact, index, snapshot, lookups)
52
+ pair = lookups[:pairs][index]
53
+ previous = pair && @pool.fetch(pair[:before_index])
54
+ entry = track(contact, previous, snapshot)
55
+ @current << entry
56
+ record = lookups[:added][index] || lookups[:uncertain][index]
57
+ observation(entry, pair, record, previous, lookups[:uncertain].key?(index))
58
+ end
59
+
60
+ def track(contact, previous, snapshot)
61
+ @next_id += 1 unless previous
62
+ { contact: contact, timeline_id: previous ? previous[:timeline_id] : @next_id,
63
+ first_seen: previous ? previous[:first_seen] : snapshot, last_seen: snapshot }
64
+ end
65
+
66
+ def observation(entry, pair, record, previous, ambiguous)
67
+ details = { event: event_for(entry, previous, ambiguous), contact: pair ? pair[:after] : record,
68
+ changes: pair ? pair[:fields] : {}.freeze, compared_with: previous && previous[:last_seen] }
69
+ details[:match] = pair&.slice(:score, :evidence)&.freeze
70
+ entry.except(:contact).merge(details).freeze
71
+ end
72
+
73
+ def event_for(entry, previous, ambiguous)
74
+ return ambiguous ? :ambiguous_start : :first_seen unless previous
75
+
76
+ previous[:last_seen][:index] < entry[:last_seen][:index] - 1 ? :reappeared : :observed
77
+ end
78
+
79
+ def absent_for(diff, snapshot)
80
+ diff[:removed].filter_map do |item|
81
+ entry = @pool.fetch(item[:index])
82
+ next unless entry[:last_seen][:index] == snapshot[:index] - 1
83
+
84
+ entry.except(:contact).merge(event: :absent, observed_at: snapshot).freeze
85
+ end
86
+ end
87
+
88
+ def retain(diff, current, snapshot)
89
+ removed = diff[:removed].map { |item| @pool.fetch(item[:index]) }
90
+ dormant = removed.select { |entry| snapshot[:index] - entry[:last_seen][:index] <= @retention }
91
+ @pool = current + dormant
92
+ raise ArgumentError, 'retained history exceeds max_contacts' if @pool.length > @max_contacts
93
+ end
94
+ end
95
+ private_constant :HistoryTracker
96
+ end
@@ -42,6 +42,17 @@ module Abbu
42
42
  raise permission_error(e.message)
43
43
  end
44
44
 
45
+ def each_contact(&block)
46
+ return enum_for(:each_contact) unless block
47
+
48
+ Parsers::SqliteParser.new(
49
+ database_paths, root_path: @path, readonly: true, diagnostics: diagnostics, strict: @strict
50
+ ).each_contact(&block)
51
+ self
52
+ rescue SQLite3::CantOpenException, Errno::EACCES => e
53
+ raise permission_error(e.message)
54
+ end
55
+
45
56
  def database_paths
46
57
  @database_paths ||= discover_database_paths.tap do |paths|
47
58
  raise NotFoundError, missing_database_message if paths.empty?
@@ -0,0 +1,159 @@
1
+ # lib/abbu/machine_command.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require_relative 'machine_output'
6
+ require_relative 'merge_preview'
7
+
8
+ module Abbu
9
+ # One JSON document per invocation. Existing human CLI paths stay separate.
10
+ class MachineCommand
11
+ OPERATIONS = %i[stats schema sources groups dedupe matches diagnostics extract_images diff].freeze
12
+ SEARCHES = %i[search email phone].freeze
13
+
14
+ def initialize(options, arguments, stdout: $stdout, stderr: $stderr)
15
+ @options = options
16
+ @arguments = arguments
17
+ @stdout = stdout
18
+ @stderr = stderr
19
+ end
20
+
21
+ def run
22
+ @input = @comparison_input = nil
23
+ validate!
24
+ @input = open_input
25
+ print_result
26
+ rescue LiveStore::Error, SystemCallError, IOError => e
27
+ failure('input_output_error', e, 1)
28
+ rescue ParseError, ArgumentError => e
29
+ failure('invalid_input', e, 2)
30
+ ensure
31
+ report_diagnostics
32
+ end
33
+
34
+ private
35
+
36
+ def print_result
37
+ payload = execute
38
+ emit(payload)
39
+ search? && payload.empty? ? 1 : 0
40
+ end
41
+
42
+ def open_input
43
+ return Abbu.open_live(@options[:live_path], strict: @options[:strict]) if live?
44
+
45
+ Abbu.open(@arguments.first, strict: @options[:strict])
46
+ end
47
+
48
+ def live?
49
+ @options[:live] || @options[:live_path]
50
+ end
51
+
52
+ def search?
53
+ SEARCHES.any? { |key| @options[key] } || @options[:date_filters]
54
+ end
55
+
56
+ def operation
57
+ OPERATIONS.find { |key| @options[key] }
58
+ end
59
+
60
+ def validate!
61
+ raise ArgumentError, '--history requires standalone JSON Lines output' if @options[:history]
62
+
63
+ raise ArgumentError, '--stream requires a standalone CSV, JSONL or vCard export' if @options[:stream]
64
+
65
+ MergePreview.validate!(@options.merge(json: true))
66
+ validate_input!
67
+ validate_operation!
68
+ return unless live? && (search? || %i[schema extract_images].include?(operation))
69
+
70
+ raise ArgumentError, 'Search, schema, and image extraction require an archive input'
71
+ end
72
+
73
+ def validate_input!
74
+ raise ArgumentError, 'Use only one live input selector' if @options[:live] && @options[:live_path]
75
+ return if live? ? @arguments.empty? : @arguments.length == 1
76
+
77
+ raise ArgumentError, 'Provide exactly one archive path, or a live input selector without positional arguments'
78
+ end
79
+
80
+ def validate_operation!
81
+ count = OPERATIONS.count { |key| @options[key] } + (search? ? 1 : 0)
82
+ if count > 1 || SEARCHES.count { |key| @options[key] } > 1
83
+ raise ArgumentError, 'Choose only one machine operation (timestamp filters may accompany one search)'
84
+ end
85
+
86
+ validate_format!(count)
87
+ end
88
+
89
+ def validate_format!(count)
90
+ if @options.values_at(:calendar_year, :calendar_stamp, :calendar_id).any?
91
+ raise ArgumentError, 'Calendar options require a standalone iCalendar export'
92
+ end
93
+
94
+ incompatible_format = @options[:format] && (@options[:format] != 'json' || count.positive?)
95
+ return unless @options[:output] || @options[:photo_mode] || incompatible_format
96
+
97
+ raise ArgumentError, 'Machine output requires stdout JSON; file exports and photo options are separate operations'
98
+ end
99
+
100
+ def execute
101
+ output = MachineOutput.new(@input)
102
+ return output.contacts(query) if search?
103
+ return listing if %i[schema sources groups].include?(operation)
104
+
105
+ case operation
106
+ when :diff then snapshot_diff
107
+ when :extract_images then output.images(@options[:extract_images])
108
+ when :dedupe then output.duplicates
109
+ when nil then output.contacts
110
+ else output.public_send(operation)
111
+ end
112
+ end
113
+
114
+ def snapshot_diff
115
+ @comparison_input = Abbu.open(@options[:diff], strict: @options[:strict])
116
+ SnapshotDiff.new(@input, @comparison_input).to_h
117
+ end
118
+
119
+ def listing
120
+ return @input.schema_report if operation == :schema
121
+
122
+ @input.public_send(operation).map(&:to_h)
123
+ end
124
+
125
+ def query
126
+ results = initial_query
127
+ @options.fetch(:date_filters, {}).each { |field, bounds| results = results.date_range(field, **bounds) }
128
+ results
129
+ end
130
+
131
+ def initial_query
132
+ return @input.search(@options[:search]) if @options[:search]
133
+ return @input.find_by_email(@options[:email]) if @options[:email]
134
+ return @input.find_by_phone(@options[:phone]) if @options[:phone]
135
+
136
+ @input.query
137
+ end
138
+
139
+ def emit(payload)
140
+ @stdout.puts(JSON.pretty_generate(payload))
141
+ end
142
+
143
+ def failure(code, error, status)
144
+ emit(error: { code: code, message: error.message })
145
+ status
146
+ end
147
+
148
+ def report_diagnostics
149
+ if @comparison_input && !@comparison_input.diagnostics.empty?
150
+ @stderr.puts("Comparison input diagnostics: #{@comparison_input.diagnostics.length}")
151
+ end
152
+ return unless @input && operation != :diagnostics
153
+
154
+ @input.diagnostics.each do |diagnostic|
155
+ @stderr.puts("- #{diagnostic.category}: #{diagnostic.message} [#{diagnostic.source}]")
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,52 @@
1
+ # lib/abbu/machine_output.rb
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'exporters/json_exporter'
5
+ require_relative 'utils/deduplicator'
6
+
7
+ module Abbu
8
+ # Serialization only: no new identity inference, parser rules, or image reads.
9
+ class MachineOutput
10
+ def initialize(input)
11
+ @input = input
12
+ end
13
+
14
+ def contacts(records = @input.contacts)
15
+ Exporters::JsonExporter.new(records).payload
16
+ end
17
+
18
+ def stats
19
+ records = @input.contacts
20
+ { total_contacts: records.count, with_email: records.count { |c| c.emails.any? },
21
+ with_phone: records.count { |c| c.phones.any? } }
22
+ end
23
+
24
+ def diagnostics
25
+ @input.contacts
26
+ @input.diagnostics.map(&:to_h)
27
+ end
28
+
29
+ def duplicates
30
+ Utils::Deduplicator.new(@input.contacts).duplicates.map do |email, records|
31
+ { email: email, contacts: contacts(records) }
32
+ end
33
+ end
34
+
35
+ def matches
36
+ Utils::Deduplicator.new(@input.contacts).matches.map do |match|
37
+ { left: contacts([match.left]).first, right: contacts([match.right]).first,
38
+ sources: match.sources, evidence: match.evidence, score: match.score,
39
+ confidence: match.confidence, status: match.status }
40
+ end
41
+ end
42
+
43
+ def images(destination)
44
+ result = @input.extract_images(destination)
45
+ files = result.files.map do |record|
46
+ record.merge(contact: contacts([record[:contact]]).first,
47
+ source_path: record[:source_path].to_s, path: record[:path].to_s)
48
+ end
49
+ { files: files, diagnostics: result.diagnostics }
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,57 @@
1
+ # lib/abbu/mcp/read_session.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Abbu
5
+ module Mcp
6
+ # One explicitly selected input, cached by ABBU's public input API. No tool
7
+ # may change its path, discover another store, export files, or apply edits.
8
+ class ReadSession
9
+ def initialize(input)
10
+ @input = input
11
+ @output = MachineOutput.new(input)
12
+ end
13
+
14
+ def query(mode:, value:, offset: 0, limit: 100)
15
+ validate_page!(offset, limit)
16
+ records = selection(mode, value).to_a
17
+ page = records.slice(offset, limit) || []
18
+ { contacts: @output.contacts(page), offset: offset, limit: limit, has_more: offset + limit < records.length }
19
+ end
20
+
21
+ def stats
22
+ @output.stats
23
+ end
24
+
25
+ def diagnostics
26
+ @output.diagnostics
27
+ end
28
+
29
+ def sources
30
+ @input.sources.map(&:to_h)
31
+ end
32
+
33
+ def groups
34
+ @input.groups.map(&:to_h)
35
+ end
36
+
37
+ private
38
+
39
+ def selection(mode, value)
40
+ query = Query.new(@input.contacts)
41
+ case mode
42
+ when 'search' then query.search(value)
43
+ when 'email' then query.find_by_email(value)
44
+ when 'phone' then query.find_by_phone(value)
45
+ when 'modified_since' then query.modified_since(value)
46
+ else raise ArgumentError, 'Unsupported query mode'
47
+ end
48
+ end
49
+
50
+ def validate_page!(offset, limit)
51
+ return if offset.is_a?(Integer) && offset >= 0 && limit.is_a?(Integer) && limit.between?(1, 500)
52
+
53
+ raise ArgumentError, 'offset must be nonnegative and limit must be between 1 and 500'
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,100 @@
1
+ # lib/abbu/mcp/server.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+
6
+ module Abbu
7
+ module Mcp
8
+ # SDK owns protocol validation/transport; this adapter only exposes an
9
+ # allowlist over public ABBU read APIs. No contact or request logging.
10
+ class Server
11
+ EMPTY_SCHEMA = { type: 'object', properties: {}, additionalProperties: false }.freeze
12
+ QUERY_SCHEMA = {
13
+ type: 'object', additionalProperties: false,
14
+ properties: {
15
+ mode: { type: 'string', enum: %w[search email phone modified_since] },
16
+ value: { type: 'string' }, offset: { type: 'integer', minimum: 0 },
17
+ limit: { type: 'integer', minimum: 1, maximum: 500 }
18
+ }, required: %w[mode value]
19
+ }.freeze
20
+ ARRAY_DATA = { type: 'array', items: { type: 'object' } }.freeze
21
+ DATA_SCHEMAS = {
22
+ query: {
23
+ type: 'object', properties: {
24
+ contacts: ARRAY_DATA, offset: { type: 'integer' }, limit: { type: 'integer' }, has_more: { type: 'boolean' }
25
+ }, required: %w[contacts offset limit has_more], additionalProperties: false
26
+ },
27
+ stats: {
28
+ type: 'object', properties: {
29
+ total_contacts: { type: 'integer' }, with_email: { type: 'integer' }, with_phone: { type: 'integer' }
30
+ }, required: %w[total_contacts with_email with_phone], additionalProperties: false
31
+ }
32
+ }.freeze
33
+ ANNOTATIONS = {
34
+ read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false
35
+ }.freeze
36
+ DESCRIPTIONS = {
37
+ query: 'Read sensitive contacts by name/email search, exact email/phone, or modified-since timestamp.',
38
+ stats: 'Read contact counts from the configured input.',
39
+ sources: 'Read observed source metadata; includes sensitive local paths and identifiers.',
40
+ groups: 'Read observed group metadata; includes sensitive group names and provenance.',
41
+ diagnostics: 'Read parser diagnostics; includes sensitive local paths, without full contact records.'
42
+ }.freeze
43
+
44
+ attr_reader :server
45
+
46
+ def initialize(path:, live: false, strict: false)
47
+ raise ArgumentError, 'An explicit nonempty path is required' unless path.is_a?(String) && !path.strip.empty?
48
+
49
+ input = live ? Abbu.open_live(path, strict: strict) : Abbu.open(path, strict: strict)
50
+ @session = ReadSession.new(input)
51
+ @server = ::MCP::Server.new(name: 'abbu', version: VERSION, tools: tools, configuration: configuration)
52
+ end
53
+
54
+ def open
55
+ ::MCP::Server::Transports::StdioTransport.new(server).open
56
+ end
57
+
58
+ private
59
+
60
+ def configuration
61
+ # SDK 1.6.1 validates successful structured content; isError results are
62
+ # deliberately exempt. Real stdio regressions protect both error paths.
63
+ ::MCP::Configuration.new(exception_reporter: ->(_exception, _context) {}, validate_tool_call_results: true)
64
+ end
65
+
66
+ def tools
67
+ handler = method(:respond)
68
+ DESCRIPTIONS.map do |operation, description|
69
+ ::MCP::Tool.define(
70
+ name: "abbu_#{operation}", description: description,
71
+ input_schema: operation == :query ? QUERY_SCHEMA : EMPTY_SCHEMA,
72
+ output_schema: output_schema(operation), annotations: ANNOTATIONS
73
+ ) do |**arguments|
74
+ handler.call(operation, arguments.except(:server_context))
75
+ end
76
+ end
77
+ end
78
+
79
+ def output_schema(operation)
80
+ { type: 'object', properties: { data: DATA_SCHEMAS.fetch(operation, ARRAY_DATA) },
81
+ required: ['data'], additionalProperties: false }
82
+ end
83
+
84
+ def respond(operation, arguments)
85
+ payload = { data: @session.public_send(operation, **arguments) }
86
+ response(payload)
87
+ rescue ArgumentError
88
+ response({ error: { code: 'invalid_query', message: 'Check mode, timestamp and pagination.' } }, error: true)
89
+ rescue StandardError
90
+ # Never echo exception messages that might include contact values or paths.
91
+ response({ error: { code: 'read_failed', message: 'Unable to read the configured input.' } }, error: true)
92
+ end
93
+
94
+ def response(payload, error: false)
95
+ ::MCP::Tool::Response.new([{ type: 'text', text: JSON.generate(payload) }],
96
+ structured_content: payload, error: error)
97
+ end
98
+ end
99
+ end
100
+ end
data/lib/abbu/mcp.rb ADDED
@@ -0,0 +1,11 @@
1
+ # lib/abbu/mcp.rb
2
+ # frozen_string_literal: true
3
+
4
+ # Optional SDK activation never runs from require 'abbu'.
5
+ gem 'mcp', '~> 1.6.1'
6
+ require 'json'
7
+ require 'mcp'
8
+
9
+ require_relative '../abbu'
10
+ require_relative 'mcp/read_session'
11
+ require_relative 'mcp/server'