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.
- checksums.yaml +4 -4
- data/README.md +129 -2
- data/bin/abbu +158 -6
- data/bin/abbu-mcp +36 -0
- data/docs/ABBU.md +28 -0
- data/docs/ALTERNATE_CALENDAR_EVIDENCE.md +82 -0
- data/docs/CHANGELOG.md +98 -0
- data/docs/CONTRIBUTING.md +3 -0
- data/docs/FUZZY_MATCHING.md +72 -0
- data/docs/HISTORY_EVIDENCE.md +98 -0
- data/docs/ICALENDAR.md +101 -0
- data/docs/MACHINE_JSON.md +128 -0
- data/docs/MCP.md +148 -0
- data/docs/MERGE_PLANS.md +78 -0
- data/docs/MY_CARD_EVIDENCE.md +92 -0
- data/docs/PORTABLE_SQLITE.md +142 -0
- data/docs/RELEASING.md +76 -68
- data/docs/SNAPSHOT_DIFF.md +74 -0
- data/docs/SNAPSHOT_HISTORY.md +93 -0
- data/docs/STREAMING.md +73 -0
- data/docs/TODO.md +4 -4
- data/docs/WRITER_DECISION.md +90 -0
- data/lib/abbu/archive.rb +14 -0
- data/lib/abbu/exporters/csv_exporter.rb +7 -0
- data/lib/abbu/exporters/icalendar_encoding.rb +35 -0
- data/lib/abbu/exporters/icalendar_event.rb +61 -0
- data/lib/abbu/exporters/icalendar_exporter.rb +86 -0
- data/lib/abbu/exporters/json_exporter.rb +2 -2
- data/lib/abbu/exporters/jsonl_exporter.rb +23 -0
- data/lib/abbu/exporters/sqlite_exporter.rb +85 -0
- data/lib/abbu/exporters/sqlite_rows.rb +113 -0
- data/lib/abbu/exporters/sqlite_schema.rb +76 -0
- data/lib/abbu/exporters/vcard_exporter.rb +10 -11
- data/lib/abbu/exporters/vcard_photo.rb +50 -0
- data/lib/abbu/history_tracker.rb +96 -0
- data/lib/abbu/live_store.rb +11 -0
- data/lib/abbu/machine_command.rb +159 -0
- data/lib/abbu/machine_output.rb +52 -0
- data/lib/abbu/mcp/read_session.rb +57 -0
- data/lib/abbu/mcp/server.rb +100 -0
- data/lib/abbu/mcp.rb +11 -0
- data/lib/abbu/merge_plan.rb +132 -0
- data/lib/abbu/merge_preview.rb +35 -0
- data/lib/abbu/merge_values.rb +26 -0
- data/lib/abbu/parsers/plist_parser.rb +9 -0
- data/lib/abbu/parsers/sqlite_parser.rb +25 -0
- data/lib/abbu/snapshot_diff.rb +125 -0
- data/lib/abbu/snapshot_history.rb +66 -0
- data/lib/abbu/utils/fuzzy_matcher.rb +116 -0
- data/lib/abbu/version.rb +1 -1
- data/lib/abbu.rb +9 -0
- data/sig/fuzzy_matcher.rbs +22 -0
- data/sig/icalendar_exporter.rbs +13 -0
- data/sig/machine_output.rbs +23 -0
- data/sig/mcp.rbs +19 -0
- data/sig/merge_plan.rbs +16 -0
- data/sig/snapshot_diff.rbs +8 -0
- data/sig/snapshot_history.rbs +10 -0
- data/sig/sqlite_exporter.rbs +10 -0
- data/sig/streaming.rbs +27 -0
- data/sig/vcard_exporter.rbs +1 -1
- 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
|
-
|
|
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
|
data/lib/abbu/live_store.rb
CHANGED
|
@@ -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'
|