From b845b21b6dcea6e8e1ffdea63cde5fec069db855 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Fri, 21 Aug 2026 22:04:01 -0400 Subject: [PATCH 1/7] feat: ingest source-aware messaging bundles --- AGENTS.md | 17 +- CHANGELOG.md | 16 + README.md | 106 +- SECURITY.md | 37 +- dist/{cli-xby0v0et.js => cli-mxxakdqk.js} | 5 +- dist/cli.js | 2621 +++++++++++++++-- dist/index.js | 4 +- dist/types.d.ts | 101 +- docs/local-message-bundle-v1.md | 207 ++ docs/methodology.md | 38 +- docs/research.md | 7 +- package.json | 6 +- schema/local-message-bundle-v1.schema.json | 434 +++ scripts/check-standalone.ts | 2 +- scripts/local-message-bundle-schema.test.ts | 59 + scripts/package-smoke.ts | 15 + site/AGENTS.md | 10 +- site/app/layout.tsx | 14 +- site/app/page.tsx | 31 +- site/app/readme.generated.ts | 2 +- site/package.json | 2 +- skills/message-like-me/SKILL.md | 26 +- skills/message-like-me/references/analysis.md | 12 +- skills/message-like-me/references/privacy.md | 8 +- src/args.ts | 1 + src/bundle.test.ts | 640 ++++ src/bundle.ts | 1442 +++++++++ src/commands.test.ts | 74 + src/commands.ts | 93 +- .../beeper-message-like-me-v1/accounts.ndjson | 1 + .../conversations.ndjson | 1 + .../beeper-message-like-me-v1/manifest.json | 1 + .../beeper-message-like-me-v1/messages.ndjson | 2 + .../participants.ndjson | 2 + .../reactions.ndjson | 1 + .../tombstones.ndjson | 1 + src/metrics.test.ts | 109 +- src/metrics.ts | 132 +- src/store.test.ts | 319 +- src/store.ts | 1417 ++++++++- src/test-bundle-fixture.ts | 228 ++ src/types.ts | 109 +- src/version.ts | 2 +- 43 files changed, 7917 insertions(+), 438 deletions(-) rename dist/{cli-xby0v0et.js => cli-mxxakdqk.js} (87%) create mode 100644 docs/local-message-bundle-v1.md create mode 100644 schema/local-message-bundle-v1.schema.json create mode 100644 scripts/local-message-bundle-schema.test.ts create mode 100644 src/bundle.test.ts create mode 100644 src/bundle.ts create mode 100644 src/fixtures/beeper-message-like-me-v1/accounts.ndjson create mode 100644 src/fixtures/beeper-message-like-me-v1/conversations.ndjson create mode 100644 src/fixtures/beeper-message-like-me-v1/manifest.json create mode 100644 src/fixtures/beeper-message-like-me-v1/messages.ndjson create mode 100644 src/fixtures/beeper-message-like-me-v1/participants.ndjson create mode 100644 src/fixtures/beeper-message-like-me-v1/reactions.ndjson create mode 100644 src/fixtures/beeper-message-like-me-v1/tombstones.ndjson create mode 100644 src/test-bundle-fixture.ts diff --git a/AGENTS.md b/AGENTS.md index 0e4343e..0aae991 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # Contents -- `src/` – the deterministic local iMessage and Contacts readers, normalized - corpus and metrics, private SQLite store, profile parser, Agent Skill - installer, and `messagelikeme` CLI. +- `src/` – the deterministic local iMessage, Contacts, and private source-bundle + readers, normalized corpus and metrics, private SQLite store, profile parser, + Agent Skill installer, and `messagelikeme` CLI. - `schema/` – public versioned JSON Schemas for deterministic artifacts and agent-authored profiles. - `docs/` – public methodology, evidence limits, research review, and prior-art @@ -25,7 +25,7 @@ - Use Bun 1.3.14 and run `bun run check` before handing off a change. Do not add another package manager or lockfile. - Keep the public description exact: “A local-first CLI and Agent Skill for - studying your private iMessage history and drafting messages that sound like + studying private messaging history and drafting messages that sound like you.” - Keep the public repository independently buildable. Do not reference another source repository, private packages, sibling paths, private fixtures, or @@ -33,6 +33,12 @@ - Keep `chat.db` authoritative and ingestion read-only, query-only, ownership-checked, schema-validated, and bounded. Never modify Messages, contacts, attachments, or SQLite sidecars. +- Treat a `message-like-me.local-message-bundle` as an untrusted, private, + versioned directory boundary. Require its fixed inventory, canonical UTF-8, + owner-only modes, bounded records, artifact digests, and manifest digest. + Never let bundle absence erase retained history unless a future contract + explicitly declares authoritative coverage; apply explicit deletions and + tombstones separately. - Treat AddressBook databases as optional label-enrichment sources. Isolate every database plus WAL or journal before SQLite opens it, validate contact entities and property owners dynamically, read only names and exact @@ -54,7 +60,8 @@ - Keep the command name `messagelikeme`, the repository and package name `message-like-me`, and the Agent Skill name `message-like-me`. Treat `messagelikeme.com` as an informational project page, never as a data plane. -- Keep CLI commands namespaced as `ingest imessage|contacts`, +- Keep CLI commands namespaced as `ingest imessage|contacts|bundle`, + `sources list|show`, `contacts list|show|resolve`, `inspect tempo|sessions`, `study prepare`, `profile apply|show|export`, plus `init`, `context`, `skill`, and `doctor`. Machine-readable commands support diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a3245f..747410f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.3.0 + +- Add strict private version-one source-bundle ingestion for local Beeper + exports produced through Wrench, including account and network provenance, + replies, edits, deletions, attachments, reactions, and tombstones. +- Namespace corpus ownership by source so native iMessage and multiple provider + accounts coexist. Bounded snapshot absence retains prior history, explicit + terminal state suppresses evidence, and later reappearance restores it. +- Add `sources list` and `sources show` with active message, conversation, + reaction, undated-reaction, completeness, and warning health. +- Partition sessions, bursts, and response episodes by conversation, preserve + truncated text bubbles as tempo evidence, and count undated reactions without + inventing timestamps. +- Upgrade existing version-two stores in place while retaining conversations, + profiles, study packets, and evidence provenance. + ## 0.2.0 - Aggregate conservatively matched direct threads into one AddressBook person diff --git a/README.md b/README.md index 8112bb7..6e3f587 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # Message Like Me -**A local-first CLI and Agent Skill for studying your private iMessage history -and drafting messages that sound like you.** +**A local-first CLI and Agent Skill for studying private messaging history and +drafting messages that sound like you.** -Message Like Me turns a local Messages database into deterministic conversation -metrics, bounded study packets, and reusable style profiles. Its Agent Skill -teaches Codex, Claude, and other coding agents how to interpret those local -artifacts and draft unsent replies in your voice. +Message Like Me turns private local messaging history into deterministic +conversation metrics, bounded study packets, and reusable style profiles. It +reads native iMessage history and strict local source bundles, including +multi-account Beeper exports produced through Wrench. Its Agent Skill teaches +Codex, Claude, and other coding agents how to interpret those local artifacts +and draft unsent replies in your voice. The CLI does not call an AI service, authenticate with a product account, send messages, or operate Messages. The agent already running the skill supplies the @@ -23,7 +25,7 @@ Message Like Me requires Bun 1.3.14 or newer. Install the immutable public release from GitHub, then install the Agent Skill: ```sh -bun add --global github:hraness/message-like-me#v0.2.0 +bun add --global github:hraness/message-like-me#v0.3.0 messagelikeme skill install ``` @@ -40,7 +42,7 @@ messagelikeme skill path Message Like Me is distributed directly through GitHub and is not published to npm. -## Start with your local history +## Start with private local history Initialize the private data store and inspect its location: @@ -79,6 +81,48 @@ copy of the database and its transactional sidecars, and opens only that copy with SQLite. It does not change Messages, `chat.db`, or its sidecars. macOS may require permission for the terminal or agent host to read Messages data. +To study accounts connected through Beeper, first ask Wrench to create a new +private Message Like Me bundle: + +```sh +wrench beeper export-message-like-me \ + --auth \ + --output /absolute/private/path/beeper-bundle \ + --json +``` + +The optional `--limit-chats`, `--limit-messages`, and `--max-participants` +flags lower the export bounds. The output path must be a normalized absolute +path to a directory that does not already exist. Wrench uses the pinned local +Beeper CLI export without attachment bytes, writes a mode-`0700` directory +with mode-`0600` files, and writes `manifest.json` last. Provider URLs and +credentials are excluded. Message Like Me does not receive the Beeper +credential and does not call Beeper or Wrench itself. + +Ingest the finished directory, then inspect its redacted source health: + +```sh +messagelikeme ingest bundle --input /absolute/private/path/beeper-bundle --json +messagelikeme sources list --json +messagelikeme sources show --json +``` + +The importer verifies the fixed version-one inventory, canonical UTF-8 NDJSON, +record and byte bounds, owner-only permissions, artifact digests, and manifest +digest before changing the store. One bundle may contain several connected +accounts and networks; each becomes a separate source namespace. Native +iMessage and prior bundle sources remain alongside it. + +The complete interchange, integrity, identity, and reimport laws are in the +[version-one local message bundle contract](docs/local-message-bundle-v1.md). + +Beeper exports describe bounded local observations. A later bounded export +that omits an older record does not delete retained history. Explicit deletion, +removal, replacement, and tombstone records suppress their target, and a later +reappearance restores it. Older snapshots cannot overwrite newer state. Use +`sources show --private --json` only when you deliberately need the +private provider account and source metadata. + Optionally enrich and join direct conversations with private identities from macOS Contacts: @@ -96,16 +140,17 @@ messagelikeme ingest contacts \ --json ``` -Contacts ingest may run before or after iMessage ingest. It reads only bounded -name, email, and phone fields from a stable private copy. Exact normalized -email or phone handles can join several one-to-one iMessage, SMS, and email -threads for the same AddressBook person into one analysis scope. Existing -conversation IDs remain aliases for that person scope. Shared handles remain -ambiguous, local phone numbers never gain a guessed country code, unmatched -threads stay separate, and groups are never collapsed to one person. Contact -labels have their own revision, so a rename does not stale a messaging-style -profile. `messagelikeme doctor` reports local aggregate state without asking -for an account or credential. +Contacts ingest may run before or after any message source. It reads only +bounded name, email, and phone fields from a stable private copy. Exact +normalized email or E.164 phone handles can join several one-to-one threads +for the same AddressBook person into one analysis scope. A bundle conversation +is eligible only when the producer positively marks its direct participant +roster complete. Existing conversation IDs remain aliases for that person +scope. Shared handles remain ambiguous, local phone numbers never gain a +guessed country code, unmatched threads stay separate, and groups are never +collapsed to one person. Contact labels have their own revision, so a rename +does not stale a messaging-style profile. `messagelikeme doctor` reports local +aggregate state without asking for an account or credential. ## Inspect behavior without exposing prose @@ -124,8 +169,13 @@ outgoing turns, within-session response latency, single-message versus multi-message replies, surface prose features, multi-point response contexts, reactions, and explicit reply use. Incoming messages establish what you were responding to; they are never counted as examples of your writing style. -Session and burst gaps are configurable seconds and are recorded with each -result. They are segmentation choices, not universal facts about conversation. +Sessions, bursts, and response episodes never cross a source conversation +boundary. Person scopes spanning several apps expose a sorted `services` +breakdown instead of hiding the mixed-channel evidence behind a null service. +Reactions with no provider timestamp still contribute to reaction counts and +direction, but never to temporal metrics. Session and burst gaps are +configurable seconds and are recorded with each result. They are segmentation +choices, not universal facts about conversation. Pass `--private` to `contacts list` or `contacts show` only when you need to resolve a pseudonymous contact to its local private label or participants. @@ -244,6 +294,9 @@ Run `messagelikeme --help` for the checked grammar. The public surfaces are: messagelikeme init [--json] messagelikeme ingest imessage [--database PATH] [--json] messagelikeme ingest contacts [--addressbook PATH] [--json] +messagelikeme ingest bundle --input ABS_PATH [--json] +messagelikeme sources list [--private] [--json] +messagelikeme sources show SOURCE_ID [--private] [--json] messagelikeme contacts list [--min-outgoing N] [--limit N] [--private] [--json] messagelikeme contacts show CONTACT_ID [--private] [--json] messagelikeme contacts resolve QUERY --private [--limit N] [--json] @@ -271,10 +324,13 @@ Place global `--data-dir PATH` before the command. - The original `chat.db` and AddressBook databases remain authoritative. SQLite opens only stable private copies, never the source files or sidecars. +- Source bundles remain private caller-owned inputs. Import verifies their + fixed inventory, canonical bytes, digests, bounds, and owner-only modes. - The normalized corpus, profiles, and installation key stay in a private local store with owner-only permissions. -- Stable contact, conversation, and message IDs are derived with a private - per-install HMAC key. Pseudonymous IDs are not encryption. +- Stable source, contact, participant, conversation, message, and reaction IDs + are derived with a private per-install HMAC key. Pseudonymous IDs are not + encryption. - Aggregate commands omit bodies and private labels. Study and evaluation packets are bounded, explicit body-bearing exports. - Message text never goes to a Message Like Me server. There is no service, @@ -312,9 +368,9 @@ bun install --frozen-lockfile --ignore-scripts bun run check ``` -Tests use synthetic Messages and AddressBook databases plus synthetic -conversations. Never add a real message, handle, group title, attachment, -contact record, private path, or derived profile to a fixture. +Tests use synthetic Messages and AddressBook databases plus synthetic source +bundles and conversations. Never add a real message, handle, group title, +attachment, contact record, private path, or derived profile to a fixture. The canonical repository is [`hraness/message-like-me`](https://github.com/hraness/message-like-me). diff --git a/SECURITY.md b/SECURITY.md index be1a95f..f698653 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,10 +11,12 @@ boundary, observed result, and a reproduction built from synthetic data. ## Private-data boundary -Message Like Me reads private iMessage history to derive local analysis. The +Message Like Me reads private messaging history to derive local analysis. The following values are sensitive even when they do not contain an obvious name: - the source Messages and AddressBook databases and their SQLite sidecars; +- local message bundles, manifests, connected-account metadata, and provider + provenance; - contact names, email addresses, and phone numbers; - message bodies, timestamps, reply links, tapbacks, and attachment metadata; - contact, participant, conversation, and group metadata; @@ -54,6 +56,36 @@ provenance. Missing or unsupported text remains unavailable rather than being guessed. Reply targets and tapbacks remain separate from prose so they cannot silently become authored style evidence. +## Local message bundle ingestion + +`messagelikeme ingest bundle` accepts only a normalized absolute path to a +current-user-owned physical mode-`0700` directory. The version-one directory +contains exactly `manifest.json` and six mode-`0600` canonical UTF-8 NDJSON +artifacts. Files must be regular, singly linked, owner-controlled, stable while +read, and free of symbolic-link traversal. + +The importer validates the manifest before allocating for its artifacts. It +caps one line at 2 MiB, the complete bundle at 500,000 records and 512 MiB, and +connected accounts at 128. It streams each artifact, rejects invalid UTF-8, +requires canonical JSON plus final newlines, and verifies exact record counts, +bytes, SHA-256 artifact digests, and the canonical manifest projection digest. +These checks detect malformed or changed local input. They do not establish +that the provider data is truthful or complete. + +The accepted privacy declaration permits attachment metadata only and requires +provider URLs and credentials to be excluded. The bundle may still contain +message bodies, names, handles, timestamps, account identifiers, and graph +coordinates. Keep it under the same controls as the normalized store, and do +not place it in Git, logs, issues, packages, or ordinary agent context. + +Each connected account is stored in its own per-install HMAC namespace. +Bounded, truncated, and unknown source absence never deletes retained history. +Explicit tombstones and terminal message or reaction state suppress their +validated targets. A later matching record can clear suppression, while an +older or conflicting equal-time snapshot is rejected. `sources list` is +redacted. `sources show --private` deliberately reveals provider account and +source metadata. + ## Contacts enrichment Contacts enrichment is optional. The reader discovers populated @@ -83,7 +115,8 @@ a prose profile. ## Local identifiers -Contact, conversation, and message identifiers are derived with an HMAC key +Source, contact, participant, conversation, message, and reaction identifiers +are derived with an HMAC key created for one local installation. They reduce accidental disclosure and keep stable local references without storing handles in ordinary views. They are not anonymization against an attacker who can read the local corpus or key. diff --git a/dist/cli-xby0v0et.js b/dist/cli-mxxakdqk.js similarity index 87% rename from dist/cli-xby0v0et.js rename to dist/cli-mxxakdqk.js index 177e0d5..1b38977 100644 --- a/dist/cli-xby0v0et.js +++ b/dist/cli-mxxakdqk.js @@ -19,11 +19,12 @@ function prettyJson(value) { // src/types.ts var CORPUS_SCHEMA_VERSION = 1; -var METRICS_SCHEMA_VERSION = 1; +var METRICS_SCHEMA_VERSION = 2; var PROFILE_SCHEMA_VERSION = 2; var LEGACY_PROFILE_SCHEMA_VERSION = 1; var STUDY_PACKET_SCHEMA_VERSION = 2; var EVALUATION_PACKET_SCHEMA_VERSION = 1; var CONTACTS_SCHEMA_VERSION = 1; +var MESSAGE_BUNDLE_SCHEMA_VERSION = 1; -export { canonicalJson, sha256, prettyJson, CORPUS_SCHEMA_VERSION, METRICS_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, LEGACY_PROFILE_SCHEMA_VERSION, STUDY_PACKET_SCHEMA_VERSION, EVALUATION_PACKET_SCHEMA_VERSION, CONTACTS_SCHEMA_VERSION }; +export { canonicalJson, sha256, prettyJson, CORPUS_SCHEMA_VERSION, METRICS_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, LEGACY_PROFILE_SCHEMA_VERSION, STUDY_PACKET_SCHEMA_VERSION, EVALUATION_PACKET_SCHEMA_VERSION, CONTACTS_SCHEMA_VERSION, MESSAGE_BUNDLE_SCHEMA_VERSION }; diff --git a/dist/cli.js b/dist/cli.js index 71290c8..cc071ad 100755 --- a/dist/cli.js +++ b/dist/cli.js @@ -5,17 +5,18 @@ import { CORPUS_SCHEMA_VERSION, EVALUATION_PACKET_SCHEMA_VERSION, LEGACY_PROFILE_SCHEMA_VERSION, + MESSAGE_BUNDLE_SCHEMA_VERSION, METRICS_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, STUDY_PACKET_SCHEMA_VERSION, canonicalJson, prettyJson, sha256 -} from "./cli-xby0v0et.js"; +} from "./cli-mxxakdqk.js"; // src/commands.ts -import { lstat as lstat3 } from "fs/promises"; -import { isAbsolute as isAbsolute4, resolve as resolve5 } from "path"; +import { lstat as lstat4 } from "fs/promises"; +import { isAbsolute as isAbsolute5, resolve as resolve6 } from "path"; // src/errors.ts var EXIT_CODES = { @@ -53,6 +54,7 @@ var VALUE_OPTIONS = new Set([ "burst-gap", "data-dir", "database", + "input", "limit", "min-outgoing", "output", @@ -134,6 +136,12 @@ function rejectUnused(parsed, allowedOptions, allowedFlags) { } } +// src/bundle.ts +import { createHash, createHmac as createHmac2 } from "crypto"; +import { constants as fsConstants2, createReadStream } from "fs"; +import { lstat, open, readdir, realpath } from "fs/promises"; +import { isAbsolute as isAbsolute2, join as join2, resolve as resolve2 } from "path"; + // src/contacts.ts import { Database } from "bun:sqlite"; import { createHmac } from "crypto"; @@ -815,12 +823,1147 @@ function readMacOSContacts(path, options) { }); } +// src/bundle.ts +var MAX_MANIFEST_BYTES = 1024 * 1024; +var MAX_RECORDS = 500000; +var MAX_RECORD_BYTES = 2 * 1024 * 1024; +var MAX_TOTAL_BYTES = 512 * 1024 * 1024; +var MAX_ACCOUNTS = 128; +var MAX_IDENTIFIER_BYTES2 = 1024; +var MAX_SHORT_TEXT_BYTES = 8 * 1024; +var MAX_BODY_BYTES = 1024 * 1024; +var MAX_PARTICIPANTS = 1e4; +var MAX_ATTACHMENTS = 256; +var MAX_WARNINGS = 128; +var ARTIFACTS = Object.freeze([ + Object.freeze({ path: "accounts.ndjson", kind: "account" }), + Object.freeze({ path: "participants.ndjson", kind: "participant" }), + Object.freeze({ path: "conversations.ndjson", kind: "conversation" }), + Object.freeze({ path: "messages.ndjson", kind: "message" }), + Object.freeze({ path: "reactions.ndjson", kind: "reaction" }), + Object.freeze({ path: "tombstones.ndjson", kind: "tombstone" }) +]); +function object(value, label) { + if (value === null || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) + throw new CliError("invalid-data", `${label} must be a plain object`); + return value; +} +function exactKeys(value, keys, label) { + const expected = [...keys].sort(); + const observed = Object.keys(value).sort(); + if (expected.length !== observed.length || observed.some((key, index) => key !== expected[index])) + throw new CliError("invalid-data", `${label} must contain exactly: ${keys.join(", ")}`); +} +function boundedText2(value, label, maximum) { + if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > maximum || value.includes("\x00")) { + throw new CliError("invalid-data", `${label} must be NUL-free text within ${maximum} UTF-8 bytes`); + } + return value; +} +function nullableText(value, label, maximum) { + return value === null ? null : boundedText2(value, label, maximum); +} +function identifier(value, label) { + const result = boundedText2(value, label, MAX_IDENTIFIER_BYTES2); + if (result.length === 0 || /[\u0000-\u001f\u007f]/u.test(result)) { + throw new CliError("invalid-data", `${label} must be a non-empty identifier without ASCII controls`); + } + return result; +} +function token(value, label, maximum = 128) { + const result = boundedText2(value, label, maximum); + if (!/^[a-z0-9](?:[a-z0-9._+-]*[a-z0-9])?$/u.test(result)) { + throw new CliError("invalid-data", `${label} must be a lowercase categorical token`); + } + return result; +} +function version(value, label) { + const result = boundedText2(value, label, 128); + if (!/^[A-Za-z0-9](?:[A-Za-z0-9._+-]*[A-Za-z0-9])?$/u.test(result)) { + throw new CliError("invalid-data", `${label} must be a bounded version token`); + } + return result; +} +function oneOf(value, values, label) { + if (typeof value !== "string" || !values.includes(value)) { + throw new CliError("invalid-data", `${label} must be one of: ${values.join(", ")}`); + } + return value; +} +function integer2(value, label, maximum = Number.MAX_SAFE_INTEGER) { + if (!Number.isSafeInteger(value) || value < 0 || value > maximum) { + throw new CliError("invalid-data", `${label} must be a non-negative safe integer`); + } + return value; +} +function nullableInteger(value, label) { + return value === null ? null : integer2(value, label); +} +function boolean(value, label) { + if (typeof value !== "boolean") + throw new CliError("invalid-data", `${label} must be boolean`); + return value; +} +function nullableBoolean(value, label) { + return value === null ? null : boolean(value, label); +} +function timestamp(value, label) { + const result = boundedText2(value, label, 64); + const date = new Date(result); + if (!Number.isFinite(date.getTime()) || date.toISOString() !== result) { + throw new CliError("invalid-data", `${label} must be a canonical UTC timestamp`); + } + return result; +} +function nullableTimestamp(value, label) { + return value === null ? null : timestamp(value, label); +} +function digest(value, label) { + const result = boundedText2(value, label, 64); + if (!/^[a-f0-9]{64}$/u.test(result)) + throw new CliError("invalid-data", `${label} must be lowercase SHA-256`); + return result; +} +function array(value, label, maximum) { + if (!Array.isArray(value) || value.length > maximum) { + throw new CliError("invalid-data", `${label} must contain at most ${maximum} items`); + } + return value; +} +function identifiers(value, label, maximum) { + const result = array(value, label, maximum).map((item, index) => identifier(item, `${label}[${index}]`)); + if (new Set(result).size !== result.length) + throw new CliError("invalid-data", `${label} repeats an ID`); + return Object.freeze(result); +} +function parseProvenance(value, label) { + const record = object(value, label); + exactKeys(record, ["providerId", "providerRevision", "observedAt", "connectedAccountProviderId"], label); + return Object.freeze({ + providerId: identifier(record.providerId, `${label}.providerId`), + providerRevision: nullableText(record.providerRevision, `${label}.providerRevision`, MAX_IDENTIFIER_BYTES2), + observedAt: timestamp(record.observedAt, `${label}.observedAt`), + connectedAccountProviderId: identifier(record.connectedAccountProviderId, `${label}.connectedAccountProviderId`) + }); +} +function parseCommon(record, kind, extraKeys, label) { + exactKeys(record, ["schemaVersion", "kind", "id", "accountId", "network", "provenance", ...extraKeys], label); + if (record.schemaVersion !== 1 || record.kind !== kind) { + throw new CliError("invalid-data", `${label} has the wrong schemaVersion or kind`); + } + return Object.freeze({ + schemaVersion: 1, + kind, + id: identifier(record.id, `${label}.id`), + accountId: identifier(record.accountId, `${label}.accountId`), + network: token(record.network, `${label}.network`, 64), + provenance: parseProvenance(record.provenance, `${label}.provenance`) + }); +} +function parseAccount(record, label) { + const common = parseCommon(record, "account", ["displayName", "handle", "selfParticipantId"], label); + if (common.id !== common.accountId || common.provenance.providerId !== common.provenance.connectedAccountProviderId) { + throw new CliError("invalid-data", `${label} does not establish one connected account realm`); + } + return Object.freeze({ + ...common, + kind: "account", + displayName: nullableText(record.displayName, `${label}.displayName`, MAX_SHORT_TEXT_BYTES), + handle: nullableText(record.handle, `${label}.handle`, MAX_SHORT_TEXT_BYTES), + selfParticipantId: identifier(record.selfParticipantId, `${label}.selfParticipantId`) + }); +} +function parseParticipant(record, label) { + const common = parseCommon(record, "participant", ["displayName", "handle", "isSelf"], label); + return Object.freeze({ + ...common, + kind: "participant", + displayName: nullableText(record.displayName, `${label}.displayName`, MAX_SHORT_TEXT_BYTES), + handle: nullableText(record.handle, `${label}.handle`, MAX_SHORT_TEXT_BYTES), + isSelf: boolean(record.isSelf, `${label}.isSelf`) + }); +} +function parseConversation(record, label) { + const common = parseCommon(record, "conversation", [ + "type", + "title", + "participantIds", + "participantsComplete", + "startedAt", + "lastMessageAt" + ], label); + const startedAt = nullableTimestamp(record.startedAt, `${label}.startedAt`); + const lastMessageAt = nullableTimestamp(record.lastMessageAt, `${label}.lastMessageAt`); + if (startedAt !== null && lastMessageAt !== null && startedAt > lastMessageAt) { + throw new CliError("invalid-data", `${label}.startedAt must not follow lastMessageAt`); + } + return Object.freeze({ + ...common, + kind: "conversation", + type: oneOf(record.type, ["direct", "group", "channel", "unknown"], `${label}.type`), + title: nullableText(record.title, `${label}.title`, MAX_SHORT_TEXT_BYTES), + participantIds: identifiers(record.participantIds, `${label}.participantIds`, MAX_PARTICIPANTS), + participantsComplete: nullableBoolean(record.participantsComplete, `${label}.participantsComplete`), + startedAt, + lastMessageAt + }); +} +function parseReply(value, label) { + if (value === null) + return null; + const record = object(value, label); + exactKeys(record, ["messageId", "providerId"], label); + return Object.freeze({ + messageId: record.messageId === null ? null : identifier(record.messageId, `${label}.messageId`), + providerId: identifier(record.providerId, `${label}.providerId`) + }); +} +function parseEdit(value, sentAt, label) { + if (value === null) + return null; + const record = object(value, label); + if (record.kind === "in-place") { + exactKeys(record, ["kind", "editedAt", "providerRevision"], label); + const editedAt2 = timestamp(record.editedAt, `${label}.editedAt`); + if (editedAt2 < sentAt) + throw new CliError("invalid-data", `${label} precedes the message`); + return Object.freeze({ + kind: "in-place", + editedAt: editedAt2, + providerRevision: identifier(record.providerRevision, `${label}.providerRevision`) + }); + } + if (record.kind !== "replacement") { + throw new CliError("invalid-data", `${label}.kind must be in-place or replacement`); + } + exactKeys(record, [ + "kind", + "replacesMessageId", + "replacesProviderId", + "editedAt", + "providerRevision" + ], label); + const replacesMessageId = record.replacesMessageId === null ? null : identifier(record.replacesMessageId, `${label}.replacesMessageId`); + const replacesProviderId = identifier(record.replacesProviderId, `${label}.replacesProviderId`); + const editedAt = timestamp(record.editedAt, `${label}.editedAt`); + if (editedAt < sentAt) + throw new CliError("invalid-data", `${label} precedes the message`); + return Object.freeze({ + kind: "replacement", + replacesMessageId, + replacesProviderId, + editedAt, + providerRevision: identifier(record.providerRevision, `${label}.providerRevision`) + }); +} +function parseDeletion(value, label) { + if (value === null) + return null; + const record = object(value, label); + exactKeys(record, ["state", "observedAt", "providerRevision"], label); + return Object.freeze({ + state: oneOf(record.state, ["revoked", "deleted-for-me", "revoked-and-deleted-for-me"], `${label}.state`), + observedAt: timestamp(record.observedAt, `${label}.observedAt`), + providerRevision: nullableText(record.providerRevision, `${label}.providerRevision`, MAX_IDENTIFIER_BYTES2) + }); +} +function parseAttachments(value, label) { + return Object.freeze(array(value, label, MAX_ATTACHMENTS).map((item, index) => { + const itemLabel = `${label}[${index}]`; + const record = object(item, itemLabel); + exactKeys(record, ["kind", "mimeType", "name", "sizeBytes"], itemLabel); + const name = nullableText(record.name, `${itemLabel}.name`, MAX_SHORT_TEXT_BYTES); + if (name !== null && (name === "." || name === ".." || name.includes("/") || name.includes("\\"))) { + throw new CliError("invalid-data", `${itemLabel}.name must not be a path`); + } + return Object.freeze({ + kind: oneOf(record.kind, ["audio", "document", "image", "link", "sticker", "video", "unknown"], `${itemLabel}.kind`), + mimeType: nullableText(record.mimeType, `${itemLabel}.mimeType`, 256), + name, + sizeBytes: nullableInteger(record.sizeBytes, `${itemLabel}.sizeBytes`) + }); + })); +} +function parseMessage(record, label) { + const common = parseCommon(record, "message", [ + "conversationId", + "senderParticipantId", + "direction", + "sentAt", + "sortKey", + "body", + "bodyTruncated", + "replyTo", + "edit", + "deletion", + "attachments" + ], label); + const sentAt = timestamp(record.sentAt, `${label}.sentAt`); + const deletion = parseDeletion(record.deletion, `${label}.deletion`); + const body = nullableText(record.body, `${label}.body`, MAX_BODY_BYTES); + if (deletion !== null && body !== null) { + throw new CliError("invalid-data", `${label}.body must be null for a deleted message`); + } + return Object.freeze({ + ...common, + kind: "message", + conversationId: identifier(record.conversationId, `${label}.conversationId`), + senderParticipantId: record.senderParticipantId === null ? null : identifier(record.senderParticipantId, `${label}.senderParticipantId`), + direction: oneOf(record.direction, ["incoming", "outgoing", "unknown"], `${label}.direction`), + sentAt, + sortKey: identifier(record.sortKey, `${label}.sortKey`), + body, + bodyTruncated: nullableBoolean(record.bodyTruncated, `${label}.bodyTruncated`), + replyTo: parseReply(record.replyTo, `${label}.replyTo`), + edit: parseEdit(record.edit, sentAt, `${label}.edit`), + deletion, + attachments: parseAttachments(record.attachments, `${label}.attachments`) + }); +} +function parseReaction(record, label) { + const common = parseCommon(record, "reaction", [ + "messageId", + "messageProviderId", + "participantId", + "body", + "reactedAt", + "state" + ], label); + return Object.freeze({ + ...common, + kind: "reaction", + messageId: record.messageId === null ? null : identifier(record.messageId, `${label}.messageId`), + messageProviderId: identifier(record.messageProviderId, `${label}.messageProviderId`), + participantId: record.participantId === null ? null : identifier(record.participantId, `${label}.participantId`), + body: boundedText2(record.body, `${label}.body`, MAX_SHORT_TEXT_BYTES), + reactedAt: nullableTimestamp(record.reactedAt, `${label}.reactedAt`), + state: oneOf(record.state, ["active", "removed"], `${label}.state`) + }); +} +function parseTombstone(record, label) { + const common = parseCommon(record, "tombstone", [ + "entityKind", + "entityId", + "entityProviderId", + "deletedAt", + "scope", + "providerRevision" + ], label); + return Object.freeze({ + ...common, + kind: "tombstone", + entityKind: oneOf(record.entityKind, ["conversation", "message", "reaction"], `${label}.entityKind`), + entityId: record.entityId === null ? null : identifier(record.entityId, `${label}.entityId`), + entityProviderId: identifier(record.entityProviderId, `${label}.entityProviderId`), + deletedAt: timestamp(record.deletedAt, `${label}.deletedAt`), + scope: oneOf(record.scope, ["remote", "local", "unknown"], `${label}.scope`), + providerRevision: nullableText(record.providerRevision, `${label}.providerRevision`, MAX_IDENTIFIER_BYTES2) + }); +} +function parseRecord(value, kind, label) { + const record = object(value, label); + switch (kind) { + case "account": + return parseAccount(record, label); + case "participant": + return parseParticipant(record, label); + case "conversation": + return parseConversation(record, label); + case "message": + return parseMessage(record, label); + case "reaction": + return parseReaction(record, label); + case "tombstone": + return parseTombstone(record, label); + } +} +function parseArtifact(value, index) { + const expected = ARTIFACTS[index]; + const label = `manifest.artifacts[${index}]`; + const record = object(value, label); + exactKeys(record, ["path", "mediaType", "recordKind", "records", "bytes", "sha256"], label); + if (record.path !== expected.path || record.mediaType !== "application/x-ndjson" || record.recordKind !== expected.kind) + throw new CliError("invalid-data", `${label} does not match the fixed artifact inventory`); + return Object.freeze({ + path: expected.path, + mediaType: "application/x-ndjson", + recordKind: expected.kind, + records: integer2(record.records, `${label}.records`, MAX_RECORDS), + bytes: integer2(record.bytes, `${label}.bytes`, MAX_TOTAL_BYTES), + sha256: digest(record.sha256, `${label}.sha256`) + }); +} +function parseManifest(value) { + const record = object(value, "manifest"); + exactKeys(record, [ + "schemaVersion", + "format", + "source", + "provider", + "timestamps", + "completeness", + "warnings", + "privacy", + "counts", + "artifacts", + "integrity" + ], "manifest"); + if (record.schemaVersion !== MESSAGE_BUNDLE_SCHEMA_VERSION || record.format !== "message-like-me.local-message-bundle") { + throw new CliError("invalid-data", "Manifest has an unsupported schemaVersion or format"); + } + const source = object(record.source, "manifest.source"); + exactKeys(source, ["id", "version"], "manifest.source"); + if (source.id !== "beeper-local") + throw new CliError("invalid-data", "manifest.source.id must be beeper-local"); + const provider = object(record.provider, "manifest.provider"); + exactKeys(provider, ["id", "version"], "manifest.provider"); + if (provider.id !== "beeper") + throw new CliError("invalid-data", "manifest.provider.id must be beeper"); + const timestamps = object(record.timestamps, "manifest.timestamps"); + exactKeys(timestamps, ["startedAt", "finishedAt", "createdAt"], "manifest.timestamps"); + const startedAt = timestamp(timestamps.startedAt, "manifest.timestamps.startedAt"); + const finishedAt = timestamp(timestamps.finishedAt, "manifest.timestamps.finishedAt"); + const createdAt = timestamp(timestamps.createdAt, "manifest.timestamps.createdAt"); + if (startedAt > finishedAt || finishedAt > createdAt) { + throw new CliError("invalid-data", "Manifest timestamps are not monotonic"); + } + const completeness = object(record.completeness, "manifest.completeness"); + exactKeys(completeness, ["kind", "reason", "observedFrom", "observedThrough"], "manifest.completeness"); + const observedFrom = nullableTimestamp(completeness.observedFrom, "manifest.completeness.observedFrom"); + const observedThrough = nullableTimestamp(completeness.observedThrough, "manifest.completeness.observedThrough"); + if (observedFrom !== null && observedThrough !== null && observedFrom > observedThrough) { + throw new CliError("invalid-data", "Manifest completeness bounds are reversed"); + } + const warnings = array(record.warnings, "manifest.warnings", MAX_WARNINGS).map((value2, index) => token(value2, `manifest.warnings[${index}]`)); + if (new Set(warnings).size !== warnings.length) + throw new CliError("invalid-data", "Manifest warnings repeat"); + const privacy = object(record.privacy, "manifest.privacy"); + exactKeys(privacy, ["classification", "attachments", "providerUrls", "credentials"], "manifest.privacy"); + if (privacy.classification !== "private-local" || privacy.attachments !== "metadata-only" || privacy.providerUrls !== "excluded" || privacy.credentials !== "excluded") + throw new CliError("invalid-data", "Manifest privacy guarantees are unsupported"); + const counts = object(record.counts, "manifest.counts"); + exactKeys(counts, ARTIFACTS.map(({ kind }) => kind), "manifest.counts"); + const parsedCounts = Object.fromEntries(ARTIFACTS.map(({ kind }) => [ + kind, + integer2(counts[kind], `manifest.counts.${kind}`, MAX_RECORDS) + ])); + if (parsedCounts.account > MAX_ACCOUNTS) { + throw new CliError("invalid-data", `Manifest exceeds the ${MAX_ACCOUNTS}-account safety bound`); + } + if (!Array.isArray(record.artifacts) || record.artifacts.length !== ARTIFACTS.length) { + throw new CliError("invalid-data", "Manifest must list the fixed six artifacts"); + } + const artifacts = Object.freeze(record.artifacts.map(parseArtifact)); + let totalRecords = 0; + let totalBytes = 0; + for (const artifact of artifacts) { + if (artifact.records !== parsedCounts[artifact.recordKind]) { + throw new CliError("invalid-data", `${artifact.path} count disagrees with manifest.counts`); + } + totalRecords += artifact.records; + totalBytes += artifact.bytes; + } + if (totalRecords > MAX_RECORDS || totalBytes > MAX_TOTAL_BYTES) { + throw new CliError("invalid-data", "Manifest exceeds the bundle record or byte bound"); + } + const integrity = object(record.integrity, "manifest.integrity"); + exactKeys(integrity, ["algorithm", "bundleSha256"], "manifest.integrity"); + if (integrity.algorithm !== "sha256") + throw new CliError("invalid-data", "Manifest integrity algorithm is unsupported"); + const result = Object.freeze({ + schemaVersion: 1, + format: "message-like-me.local-message-bundle", + source: Object.freeze({ id: "beeper-local", version: version(source.version, "manifest.source.version") }), + provider: Object.freeze({ id: "beeper", version: version(provider.version, "manifest.provider.version") }), + timestamps: Object.freeze({ startedAt, finishedAt, createdAt }), + completeness: Object.freeze({ + kind: oneOf(completeness.kind, ["bounded-local", "truncated", "unknown"], "manifest.completeness.kind"), + reason: completeness.reason === null ? null : token(completeness.reason, "manifest.completeness.reason"), + observedFrom, + observedThrough + }), + warnings: Object.freeze(warnings), + privacy: Object.freeze({ + classification: "private-local", + attachments: "metadata-only", + providerUrls: "excluded", + credentials: "excluded" + }), + counts: Object.freeze(parsedCounts), + artifacts, + integrity: Object.freeze({ algorithm: "sha256", bundleSha256: digest(integrity.bundleSha256, "manifest.integrity.bundleSha256") }) + }); + const { integrity: _integrity, ...projection } = result; + if (sha256(canonicalJson(projection)) !== result.integrity.bundleSha256) { + throw new CliError("invalid-data", "Manifest bundle SHA-256 does not match its canonical projection"); + } + return result; +} +function sameFile2(left, right) { + return left.dev === right.dev && left.ino === right.ino; +} +async function bundleDirectory(path) { + if (!isAbsolute2(path) || resolve2(path) !== path) { + throw new CliError("unsafe-path", "Bundle input must be a normalized absolute path"); + } + const before = await lstat(path); + if (!before.isDirectory() || before.isSymbolicLink() || (before.mode & 511) !== 448 || typeof process.getuid === "function" && before.uid !== process.getuid()) + throw new CliError("unsafe-path", "Bundle input must be a current-user-owned mode-0700 physical directory"); + const physical = await realpath(path); + if (physical !== path) + throw new CliError("unsafe-path", "Bundle input path must not traverse a symbolic link"); + const after = await lstat(physical); + if (!sameFile2(before, after)) + throw new CliError("unsafe-path", "Bundle directory changed while resolving"); + const expected = ["manifest.json", ...ARTIFACTS.map(({ path: artifactPath }) => artifactPath)].sort(); + const entries = (await readdir(physical)).sort(); + if (entries.length !== expected.length || entries.some((entry, index) => entry !== expected[index])) { + throw new CliError("invalid-data", "Bundle directory does not contain exactly the version-one inventory"); + } + return physical; +} +async function openPrivateFile(path, maximumBytes, allowEmpty) { + const handle = await open(path, fsConstants2.O_RDONLY | fsConstants2.O_NOFOLLOW); + try { + const before = await handle.stat({ bigint: true }); + if (!before.isFile() || before.nlink !== 1n || before.size > BigInt(maximumBytes) || !allowEmpty && before.size < 1n || (before.mode & 0o777n) !== 0o600n || typeof process.getuid === "function" && before.uid !== BigInt(process.getuid())) + throw new CliError("unsafe-path", `${path} must be a private physical file within its bound`); + return { handle, before }; + } catch (error) { + await handle.close(); + throw error; + } +} +async function assertFileUnchanged(path, handle, before) { + const after = await handle.stat({ bigint: true }); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs) + throw new CliError("unsafe-path", `${path} changed while it was read`); +} +async function closeReadHandle(handle) { + try { + await handle.close(); + } catch (error) { + if (error.code !== "EBADF") + throw error; + } +} +function decodeUtf8(bytes, label) { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + throw new CliError("invalid-data", `${label} is not valid UTF-8`, { cause: error }); + } +} +async function readManifest(path) { + const opened = await openPrivateFile(path, MAX_MANIFEST_BYTES, false); + try { + const bytes = Uint8Array.from(await opened.handle.readFile()); + await assertFileUnchanged(path, opened.handle, opened.before); + let value; + try { + value = JSON.parse(decodeUtf8(bytes, "manifest.json")); + } catch (error) { + throw new CliError("invalid-data", "manifest.json is not valid UTF-8 JSON", { cause: error }); + } + const manifest = parseManifest(value); + if (!Buffer.from(`${canonicalJson(manifest)} +`, "utf8").equals(Buffer.from(bytes))) { + throw new CliError("invalid-data", "manifest.json must use canonical JSON with one final newline"); + } + return Object.freeze({ bytes, manifest }); + } finally { + await closeReadHandle(opened.handle); + } +} +async function readArtifact(root, artifact) { + const path = join2(root, artifact.path); + const opened = await openPrivateFile(path, artifact.bytes, true); + const hash = createHash("sha256"); + const records = []; + let totalBytes = 0; + let pending = Buffer.alloc(0); + let endedWithNewline = false; + try { + const stream = createReadStream(path, { + fd: opened.handle.fd, + autoClose: false, + start: 0, + highWaterMark: 64 * 1024 + }); + for await (const value of stream) { + const chunk = Buffer.from(value); + hash.update(chunk); + totalBytes += chunk.byteLength; + if (totalBytes > artifact.bytes) + throw new CliError("invalid-data", `${artifact.path} exceeds manifest bytes`); + pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk]); + let newline = pending.indexOf(10); + while (newline >= 0) { + const line = pending.subarray(0, newline); + pending = pending.subarray(newline + 1); + endedWithNewline = true; + if (line.byteLength < 1 || line.byteLength + 1 > MAX_RECORD_BYTES) { + throw new CliError("invalid-data", `${artifact.path} contains a blank or oversized record`); + } + let parsed; + try { + parsed = JSON.parse(decodeUtf8(line, `${artifact.path} record`)); + } catch (error) { + throw new CliError("invalid-data", `${artifact.path} contains invalid UTF-8 JSON`, { cause: error }); + } + const normalized = parseRecord(parsed, artifact.recordKind, `${artifact.path}:${records.length + 1}`); + if (!Buffer.from(canonicalJson(normalized), "utf8").equals(line)) { + throw new CliError("invalid-data", `${artifact.path} records must use canonical JSON`); + } + records.push(normalized); + if (records.length > artifact.records) { + throw new CliError("invalid-data", `${artifact.path} exceeds its manifest record count`); + } + newline = pending.indexOf(10); + } + if (pending.byteLength + 1 > MAX_RECORD_BYTES) { + throw new CliError("invalid-data", `${artifact.path} contains an oversized record`); + } + if (pending.length > 0) + endedWithNewline = false; + } + await assertFileUnchanged(path, opened.handle, opened.before); + } finally { + await closeReadHandle(opened.handle); + } + if (pending.byteLength !== 0 || artifact.records > 0 && !endedWithNewline) { + throw new CliError("invalid-data", `${artifact.path} must end every record with a newline`); + } + if (totalBytes !== artifact.bytes || records.length !== artifact.records || hash.digest("hex") !== artifact.sha256) + throw new CliError("invalid-data", `${artifact.path} does not match its manifest integrity`); + return Object.freeze(records); +} +function hmacKey(value) { + const key = typeof value === "string" ? new TextEncoder().encode(value) : value; + if (!(key instanceof Uint8Array) || key.byteLength < 16 || key.byteLength > 1024) { + throw new CliError("invalid-data", "Bundle HMAC key must contain 16 through 1024 bytes"); + } + return Uint8Array.from(key); +} +function hmac2(key, namespace, value) { + return createHmac2("sha256", key).update(`message-like-me\x00bundle-${namespace}\x00`, "utf8").update(value, "utf8").digest("hex"); +} +function compareCodeUnits(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} +function recordMap(records, label) { + const result = new Map; + for (const record of records) { + if (result.has(record.id)) + throw new CliError("invalid-data", `${label} repeats a bundle-local ID`); + result.set(record.id, record); + } + return result; +} +function groupByAccount(records) { + const grouped = new Map; + for (const record of records) { + const values = grouped.get(record.accountId) ?? []; + values.push(record); + grouped.set(record.accountId, values); + } + return grouped; +} +function attachmentProvenance(messageId, attachments) { + return Object.freeze(attachments.map((attachment, index) => ({ + id: `${messageId}:attachment:${index + 1}`, + kind: attachment.kind, + mimeType: attachment.mimeType, + fileName: attachment.name, + bytes: attachment.sizeBytes + }))); +} +function reactionTimelineCoordinate(localReactionId) { + return `\x1Freaction-timeline:${localReactionId}`; +} +function normalizeBundle(manifest, manifestSha256, records, key) { + const accounts = records.account; + const participants = records.participant; + const conversations = records.conversation; + const messages = records.message; + const reactions = records.reaction; + const tombstones = records.tombstone; + if (accounts.length > MAX_ACCOUNTS) { + throw new CliError("invalid-data", `Bundle exceeds the ${MAX_ACCOUNTS}-account safety bound`); + } + const accountById = recordMap(accounts, "accounts"); + recordMap(participants, "participants"); + recordMap(conversations, "conversations"); + const messageRecordById = recordMap(messages, "messages"); + const reactionById = recordMap(reactions, "reactions"); + recordMap(tombstones, "tombstones"); + for (const [kind, values] of [ + ["account", accounts], + ["participant", participants], + ["conversation", conversations], + ["message", messages], + ["reaction", reactions], + ["tombstone", tombstones] + ]) { + const providerCoordinates = new Set; + for (const record of values) { + const coordinate = `${record.accountId}\x00${record.provenance.providerId}`; + if (providerCoordinates.has(coordinate)) { + throw new CliError("invalid-data", `${kind} records repeat a provider identity within one account`); + } + providerCoordinates.add(coordinate); + } + } + for (const record of [...participants, ...conversations, ...messages, ...reactions, ...tombstones]) { + const account = accountById.get(record.accountId); + if (account === undefined || account.network !== record.network || account.provenance.connectedAccountProviderId !== record.provenance.connectedAccountProviderId) + throw new CliError("invalid-data", "A record does not match its connected account realm"); + } + const participantsByAccount = groupByAccount(participants); + const conversationsByAccount = groupByAccount(conversations); + const messagesByAccount = groupByAccount(messages); + const reactionsByAccount = groupByAccount(reactions); + const tombstonesByAccount = groupByAccount(tombstones); + const result = []; + const sourceIds = new Set; + for (const account of accounts) { + const accountParticipants = participantsByAccount.get(account.id) ?? []; + const participantById = new Map(accountParticipants.map((participant) => [participant.id, participant])); + const self = participantById.get(account.selfParticipantId); + if (self === undefined || !self.isSelf || accountParticipants.filter(({ isSelf }) => isSelf).length !== 1) { + throw new CliError("invalid-data", "An account must have exactly one matching self participant"); + } + const accountConversations = conversationsByAccount.get(account.id) ?? []; + const conversationParticipantIds = new Map(accountConversations.map((conversation) => [ + conversation.id, + new Set(conversation.participantIds) + ])); + for (const conversation of accountConversations) { + for (const participantId of conversation.participantIds) { + if (!participantById.has(participantId)) { + throw new CliError("invalid-data", "A conversation references an unknown participant"); + } + } + if (conversation.type === "direct" && conversation.participantsComplete === true && (conversation.participantIds.length !== 2 || !conversation.participantIds.includes(account.selfParticipantId) || conversation.participantIds.filter((participantId) => participantById.get(participantId)?.isSelf === false).length !== 1)) { + throw new CliError("invalid-data", "A complete direct conversation must contain one self and one non-self participant"); + } + } + const conversationById = new Map(accountConversations.map((conversation) => [conversation.id, conversation])); + const namespace = [ + manifest.provider.id, + account.provenance.connectedAccountProviderId, + self.provenance.providerId + ].join("\x00"); + const sourceId = `source_${hmac2(key, "source", namespace)}`; + if (sourceIds.has(sourceId)) { + throw new CliError("invalid-data", "Connected accounts repeat a stable source realm"); + } + sourceIds.add(sourceId); + const conversationLocalIds = new Map(accountConversations.map((conversation) => [ + conversation.id, + `conversation_${hmac2(key, "conversation", `${namespace}\x00${conversation.provenance.providerId}`)}` + ])); + const participantLocalIds = new Map(accountParticipants.map((participant) => [ + participant.id, + `participant_${hmac2(key, "participant", `${namespace}\x00${participant.provenance.providerId}`)}` + ])); + const normalizedConversations = accountConversations.map((conversation) => { + const known = conversation.participantIds.flatMap((id) => { + const participant = participantById.get(id); + return participant === undefined ? [] : [participant]; + }); + const peers = known.filter(({ isSelf }) => !isSelf); + const completeDirectPeer = conversation.type === "direct" && conversation.participantsComplete === true && peers.length === 1 ? peers[0] : null; + const canonicalHandle = completeDirectPeer?.handle === null || completeDirectPeer === null ? null : normalizeContactHandle(completeDirectPeer.handle); + return Object.freeze({ + id: conversationLocalIds.get(conversation.id), + sourceKey: conversation.provenance.providerId, + privateLabel: conversation.title, + service: account.network, + participantCount: conversation.type === "direct" ? 1 : peers.length, + participantIds: Object.freeze(peers.map((participant) => participantLocalIds.get(participant.id))), + privateParticipants: canonicalHandle === null ? Object.freeze([]) : Object.freeze([canonicalHandle.normalizedValue]), + group: conversation.type !== "direct" + }); + }); + const accountMessages = messagesByAccount.get(account.id) ?? []; + const messageById = new Map(accountMessages.map((message) => [message.id, message])); + const messageByProviderId = new Map(accountMessages.map((message) => [ + message.provenance.providerId, + message + ])); + const replacementTargets = new Map; + const replacerByTarget = new Map; + for (const message of accountMessages) { + if (!conversationById.has(message.conversationId)) { + throw new CliError("invalid-data", "A message references an unknown conversation"); + } + if (message.senderParticipantId !== null && !participantById.has(message.senderParticipantId)) { + throw new CliError("invalid-data", "A message references an unknown sender participant"); + } + const sender = message.senderParticipantId === null ? null : participantById.get(message.senderParticipantId); + const conversation = conversationById.get(message.conversationId); + if (sender !== null && (message.direction === "outgoing" && !sender.isSelf || message.direction === "incoming" && sender.isSelf)) + throw new CliError("invalid-data", "A message direction conflicts with its sender identity"); + if (sender !== null && conversation.participantsComplete === true && !conversationParticipantIds.get(conversation.id).has(sender.id)) + throw new CliError("invalid-data", "A message sender is outside its complete conversation roster"); + if (message.replyTo !== null) { + const localTarget = message.replyTo.messageId === null ? undefined : messageRecordById.get(message.replyTo.messageId); + if (message.replyTo.messageId !== null && (localTarget === undefined || localTarget.accountId !== account.id || localTarget.provenance.providerId !== message.replyTo.providerId)) + throw new CliError("invalid-data", "A message reply has mismatched target coordinates"); + const providerTarget = messageByProviderId.get(message.replyTo.providerId); + const target = localTarget ?? providerTarget; + if (message.replyTo.providerId === message.provenance.providerId || target !== undefined && target.conversationId !== message.conversationId) + throw new CliError("invalid-data", "A message reply has an invalid conversation target"); + } + if (message.edit?.kind === "replacement") { + const localTarget = message.edit.replacesMessageId === null ? undefined : messageRecordById.get(message.edit.replacesMessageId); + if (message.edit.replacesMessageId !== null && (localTarget === undefined || localTarget.accountId !== account.id || localTarget.provenance.providerId !== message.edit.replacesProviderId)) + throw new CliError("invalid-data", "A message edit has mismatched replacement coordinates"); + const providerTarget = messageByProviderId.get(message.edit.replacesProviderId); + const target = localTarget ?? providerTarget; + if (message.edit.replacesProviderId === message.provenance.providerId || target !== undefined && target.conversationId !== message.conversationId) + throw new CliError("invalid-data", "A message edit has an invalid replacement target"); + if (replacerByTarget.has(message.edit.replacesProviderId)) { + throw new CliError("invalid-data", "A message version has multiple replacements"); + } + replacerByTarget.set(message.edit.replacesProviderId, message.provenance.providerId); + replacementTargets.set(message.id, Object.freeze({ + target, + externalId: message.edit.replacesProviderId + })); + } + } + const editEdges = new Map([...replacementTargets.entries()].map(([messageId, target]) => [ + messageById.get(messageId).provenance.providerId, + target.externalId + ])); + const completedEditNodes = new Set; + for (const start of editEdges.keys()) { + if (completedEditNodes.has(start)) + continue; + const seen = new Set; + const chain = []; + let current = start; + while (current !== undefined && !completedEditNodes.has(current)) { + if (seen.has(current)) + throw new CliError("invalid-data", "Message replacement edits contain a cycle"); + seen.add(current); + chain.push(current); + current = editEdges.get(current); + } + for (const node of chain) + completedEditNodes.add(node); + } + const analyzableMessages = accountMessages.filter(({ direction }) => direction !== "unknown").sort((left, right) => compareCodeUnits(left.conversationId, right.conversationId) || compareCodeUnits(left.sortKey, right.sortKey) || compareCodeUnits(left.sentAt, right.sentAt) || compareCodeUnits(left.id, right.id)); + const normalizedMessages = []; + const messageProvenance = []; + const localMessageIds = new Map; + const localReactionIds = new Map; + const timelineReactionIds = new Set; + for (const [index, message] of analyzableMessages.entries()) { + const localId = `message_${hmac2(key, "message", `${namespace}\x00${message.provenance.providerId}`)}`; + localMessageIds.set(message.id, localId); + const body = message.bodyTruncated === true || message.deletion !== null ? null : message.body; + normalizedMessages.push(Object.freeze({ + id: localId, + sourceRowId: index + 1, + sourceGuid: message.provenance.providerId, + conversationId: conversationLocalIds.get(message.conversationId), + sentAt: message.sentAt, + direction: message.direction, + body, + bodySource: body === null ? "unavailable" : "text", + kind: body !== null || message.bodyTruncated === true ? "text" : message.attachments.length > 0 ? "attachment" : "unknown", + replyToSourceGuid: message.replyTo?.providerId ?? null, + editedAt: message.edit?.editedAt ?? null, + retractedAt: message.deletion?.observedAt ?? null, + service: account.network, + attachmentCount: message.attachments.length + })); + messageProvenance.push(Object.freeze({ + messageId: localId, + externalId: message.provenance.providerId, + replyToExternalId: message.replyTo?.providerId ?? null, + attachments: attachmentProvenance(localId, message.attachments), + metadata: message + })); + } + const accountReactions = reactionsByAccount.get(account.id) ?? []; + for (const reaction of accountReactions) { + localReactionIds.set(reaction.id, `message_${hmac2(key, "reaction", `${namespace}\x00${reaction.provenance.providerId}`)}`); + } + const reactionFacts = []; + for (const reaction of accountReactions) { + const localTarget = reaction.messageId === null ? undefined : messageRecordById.get(reaction.messageId); + if (reaction.messageId !== null && (localTarget === undefined || localTarget.accountId !== account.id || localTarget.provenance.providerId !== reaction.messageProviderId)) + throw new CliError("invalid-data", "A reaction has mismatched target coordinates"); + if (reaction.participantId !== null && !participantById.has(reaction.participantId)) { + throw new CliError("invalid-data", "A reaction references an unknown participant"); + } + const target = localTarget ?? messageByProviderId.get(reaction.messageProviderId); + const participant = reaction.participantId === null ? null : participantById.get(reaction.participantId); + const targetConversationId = target === undefined ? null : conversationLocalIds.get(target.conversationId) ?? null; + if (target !== undefined && participant !== null) { + const targetConversation = conversationById.get(target.conversationId); + if (targetConversation.participantsComplete === true && !conversationParticipantIds.get(targetConversation.id).has(participant.id)) + throw new CliError("invalid-data", "A reaction participant is outside its complete conversation roster"); + } + const localId = localReactionIds.get(reaction.id); + reactionFacts.push(Object.freeze({ + id: localId, + externalId: reaction.provenance.providerId, + targetExternalId: reaction.messageProviderId, + conversationId: targetConversationId, + direction: participant === null ? null : participant.isSelf ? "outgoing" : "incoming", + body: reaction.body, + reactedAt: reaction.reactedAt, + state: reaction.state + })); + if (reaction.state !== "active" || reaction.reactedAt === null || reaction.participantId === null) + continue; + if (participant === null || target === undefined || targetConversationId === null) + continue; + timelineReactionIds.add(reaction.id); + const timelineCoordinate = reactionTimelineCoordinate(localId); + normalizedMessages.push(Object.freeze({ + id: localId, + sourceRowId: normalizedMessages.length + 1, + sourceGuid: timelineCoordinate, + conversationId: targetConversationId, + sentAt: reaction.reactedAt, + direction: participant.isSelf ? "outgoing" : "incoming", + body: null, + bodySource: "unavailable", + kind: "reaction", + replyToSourceGuid: reaction.messageProviderId, + editedAt: null, + retractedAt: null, + service: account.network, + attachmentCount: 0 + })); + messageProvenance.push(Object.freeze({ + messageId: localId, + externalId: timelineCoordinate, + replyToExternalId: reaction.messageProviderId, + attachments: Object.freeze([]), + metadata: reaction + })); + } + const reactionFactByExternal = new Map(reactionFacts.map((fact) => [fact.externalId, fact])); + const auxiliaryRecords = [ + { kind: "account", id: account.provenance.providerId, record: account }, + ...accountParticipants.map((participant) => ({ + kind: "participant", + id: participant.provenance.providerId, + record: participant + })), + ...accountReactions.map((reaction) => ({ + kind: "reaction", + id: reaction.provenance.providerId, + record: reaction + })), + ...(tombstonesByAccount.get(account.id) ?? []).map((tombstone) => ({ + kind: "tombstone", + id: tombstone.provenance.providerId, + record: tombstone + })), + ...accountMessages.filter(({ direction }) => direction === "unknown").map((message) => ({ + kind: "excluded-message", + id: message.provenance.providerId, + record: message + })) + ]; + const accountTombstones = tombstonesByAccount.get(account.id) ?? []; + const deletions = accountTombstones.map((tombstone) => { + const entityId = tombstone.entityId; + let localEntityId = null; + if (entityId !== null) { + if (tombstone.entityKind === "conversation") { + const target = conversationById.get(entityId); + if (target === undefined) { + throw new CliError("invalid-data", "A tombstone references an unknown local conversation"); + } + if (target.provenance.providerId !== tombstone.entityProviderId) { + throw new CliError("invalid-data", "A tombstone has mismatched conversation identity"); + } + localEntityId = conversationLocalIds.get(entityId) ?? null; + } else if (tombstone.entityKind === "message") { + const target = messageRecordById.get(entityId); + if (target === undefined || target.accountId !== account.id) { + throw new CliError("invalid-data", "A tombstone references an unknown local message"); + } + if (target.provenance.providerId !== tombstone.entityProviderId) { + throw new CliError("invalid-data", "A tombstone has mismatched message identity"); + } + localEntityId = localMessageIds.get(entityId) ?? null; + } else if (tombstone.entityKind === "reaction") { + const target = reactionById.get(entityId); + if (target === undefined || target.accountId !== account.id) { + throw new CliError("invalid-data", "A tombstone references an unknown local reaction"); + } + if (target.provenance.providerId !== tombstone.entityProviderId) { + throw new CliError("invalid-data", "A tombstone has mismatched reaction identity"); + } + localEntityId = localReactionIds.get(entityId) ?? null; + } + } + return Object.freeze({ + entityKind: tombstone.entityKind, + localEntityId, + externalId: tombstone.entityProviderId, + deletedAt: tombstone.deletedAt, + reason: "tombstone" + }); + }); + for (const [messageId, replacement] of replacementTargets) { + const message = messageById.get(messageId); + deletions.push(Object.freeze({ + entityKind: "message", + localEntityId: replacement.target === undefined ? null : localMessageIds.get(replacement.target.id) ?? null, + externalId: replacement.externalId, + deletedAt: message.edit.editedAt, + expectedConversationId: conversationLocalIds.get(message.conversationId), + reason: "replacement" + })); + } + for (const message of accountMessages) { + if (message.deletion === null) + continue; + deletions.push(Object.freeze({ + entityKind: "message", + localEntityId: localMessageIds.get(message.id) ?? null, + externalId: message.provenance.providerId, + deletedAt: message.deletion.observedAt, + expectedConversationId: conversationLocalIds.get(message.conversationId), + reason: "tombstone" + })); + } + for (const message of accountMessages) { + if (message.direction !== "unknown") + continue; + deletions.push(Object.freeze({ + entityKind: "message", + localEntityId: null, + externalId: message.provenance.providerId, + deletedAt: message.provenance.observedAt, + expectedConversationId: conversationLocalIds.get(message.conversationId), + reason: "explicit-exclusion" + })); + } + for (const reaction of accountReactions) { + if (timelineReactionIds.has(reaction.id)) + continue; + const fact = reactionFactByExternal.get(reaction.provenance.providerId); + deletions.push(Object.freeze({ + entityKind: reaction.state === "removed" ? "reaction" : "reaction-timeline", + localEntityId: localReactionIds.get(reaction.id), + externalId: reaction.provenance.providerId, + deletedAt: reaction.provenance.observedAt, + ...fact.conversationId === null ? {} : { expectedConversationId: fact.conversationId }, + reason: reaction.state === "removed" ? "tombstone" : "explicit-exclusion" + })); + } + const sourceWarnings = [...manifest.warnings]; + const unknownDirections = accountMessages.filter(({ direction }) => direction === "unknown").length; + const undatedReactions = accountReactions.filter(({ reactedAt }) => reactedAt === null).length; + if (unknownDirections > 0) + sourceWarnings.push(`unknown-direction-messages:${unknownDirections}`); + if (undatedReactions > 0) + sourceWarnings.push(`undated-reactions:${undatedReactions}`); + const accountTimelineBounds = [ + ...accountMessages.map(({ sentAt }) => sentAt), + ...accountReactions.flatMap(({ reactedAt }) => reactedAt === null ? [] : [reactedAt]) + ].sort(compareCodeUnits); + const accountObservedFrom = accountTimelineBounds[0] ?? null; + const accountObservedThrough = accountTimelineBounds.at(-1) ?? null; + const revisionHash = createHash("sha256"); + const revisionHeader = canonicalJson({ + schemaVersion: 1, + source: manifest.source, + provider: manifest.provider, + completeness: manifest.completeness, + warnings: manifest.warnings + }); + revisionHash.update(`${revisionHeader.length}:`, "utf8").update(revisionHeader, "utf8"); + for (const [kind, values] of [ + ["account", [account]], + ["participant", accountParticipants], + ["conversation", accountConversations], + ["message", accountMessages], + ["reaction", accountReactions], + ["tombstone", accountTombstones] + ]) { + revisionHash.update(`${kind.length}:${kind}`, "utf8"); + for (const record of values) { + const encoded = canonicalJson(record); + revisionHash.update(`${Buffer.byteLength(encoded, "utf8")}:`, "utf8").update(encoded, "utf8"); + } + } + const revision = revisionHash.digest("hex"); + result.push(Object.freeze({ + source: Object.freeze({ + id: sourceId, + kind: "bundle", + provider: manifest.provider.id, + network: account.network, + accountId: account.provenance.connectedAccountProviderId, + externalId: account.provenance.connectedAccountProviderId, + revision, + generatedAt: manifest.timestamps.createdAt, + producer: manifest.source, + coverage: Object.freeze({ + history: manifest.completeness.kind === "unknown" ? "unknown" : "bounded", + observedFrom: accountObservedFrom, + observedTo: accountObservedThrough, + kind: manifest.completeness.kind, + reason: manifest.completeness.reason + }), + manifestSha256, + identity: Object.freeze({ account, selfParticipantProviderId: self.provenance.providerId }), + warnings: Object.freeze(sourceWarnings) + }), + conversations: Object.freeze(normalizedConversations), + conversationProvenance: Object.freeze(accountConversations.map((conversation) => ({ + conversationId: conversationLocalIds.get(conversation.id), + externalId: conversation.provenance.providerId, + metadata: conversation + }))), + messages: Object.freeze(normalizedMessages), + messageProvenance: Object.freeze(messageProvenance), + reactionFacts: Object.freeze(reactionFacts), + auxiliaryRecords: Object.freeze(auxiliaryRecords), + deletions: Object.freeze(deletions) + })); + } + return Object.freeze(result); +} +async function readMessageBundle(path, options) { + const key = hmacKey(options.hmacKey); + const root = await bundleDirectory(path); + const manifestResult = await readManifest(join2(root, "manifest.json")); + const manifest = manifestResult.manifest; + const manifestSha256 = sha256(manifestResult.bytes); + const parsedRecords = []; + for (const artifact of manifest.artifacts) + parsedRecords.push(await readArtifact(root, artifact)); + const records = Object.fromEntries(manifest.artifacts.map((artifact, index) => [ + artifact.recordKind, + parsedRecords[index] + ])); + return Object.freeze({ + schemaVersion: MESSAGE_BUNDLE_SCHEMA_VERSION, + manifestSha256, + sources: normalizeBundle(manifest, manifestSha256, records, key) + }); +} + // src/imessage.ts import { Database as Database2 } from "bun:sqlite"; -import { createHash, createHmac as createHmac2 } from "crypto"; +import { createHash as createHash2, createHmac as createHmac3 } from "crypto"; import { chmodSync as chmodSync2, - constants as fsConstants2, + constants as fsConstants3, copyFileSync as copyFileSync2, lstatSync as lstatSync2, mkdirSync as mkdirSync2, @@ -829,8 +1972,8 @@ import { rmSync as rmSync2 } from "fs"; import { homedir as homedir2, tmpdir as tmpdir2 } from "os"; -import { basename as basename2, isAbsolute as isAbsolute2, join as join2, resolve as resolve2 } from "path"; -var DEFAULT_IMESSAGE_DATABASE = join2(homedir2(), "Library", "Messages", "chat.db"); +import { basename as basename2, isAbsolute as isAbsolute3, join as join3, resolve as resolve3 } from "path"; +var DEFAULT_IMESSAGE_DATABASE = join3(homedir2(), "Library", "Messages", "chat.db"); var APPLE_EPOCH_MILLISECONDS = Date.UTC(2001, 0, 1); var DEFAULT_MAX_DATABASE_BYTES = 16 * 1024 * 1024 * 1024; var MAX_CONFIGURABLE_DATABASE_BYTES = 64 * 1024 * 1024 * 1024; @@ -879,12 +2022,12 @@ function stableJson(value) { return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`; } function sha2562(value) { - return createHash("sha256").update(value).digest("hex"); + return createHash2("sha256").update(value).digest("hex"); } -function hmac2(key, namespace, value) { - return createHmac2("sha256", key).update(`message-like-me\x00${namespace}\x00`, "utf8").update(value, "utf8").digest("hex"); +function hmac3(key, namespace, value) { + return createHmac3("sha256", key).update(`message-like-me\x00${namespace}\x00`, "utf8").update(value, "utf8").digest("hex"); } -function hmacKey(value) { +function hmacKey2(value) { const key = typeof value === "string" ? new TextEncoder().encode(value) : value; if (!(key instanceof Uint8Array) || key.byteLength < 16 || key.byteLength > 1024) { throw new Error("iMessage HMAC key must contain 16 through 1024 bytes"); @@ -901,20 +2044,20 @@ function boundedInteger2(value, fallback, minimum, maximum, label) { function ownedByCurrentUser(stats) { return typeof process.getuid !== "function" || stats.uid === BigInt(process.getuid()); } -function sameFile2(left, right) { +function sameFile3(left, right) { return left.dev === right.dev && left.ino === right.ino; } function inspectSource(path, maximumBytes) { - if (!isAbsolute2(path)) + if (!isAbsolute3(path)) return fail2("path must be absolute"); - const requested = resolve2(path); + const requested = resolve3(path); const requestedStats = lstatSync2(requested, { bigint: true }); if (!requestedStats.isFile() || requestedStats.isSymbolicLink() || requestedStats.nlink !== 1n || !ownedByCurrentUser(requestedStats) || requestedStats.size < 1n || requestedStats.size > BigInt(maximumBytes)) { return fail2("must be one current-user-owned regular non-symlink file within the configured size bound"); } const physicalPath = realpathSync2(requested); const physicalStats = lstatSync2(physicalPath, { bigint: true }); - if (!sameFile2(requestedStats, physicalStats)) { + if (!sameFile3(requestedStats, physicalStats)) { return fail2("changed identity while its path was resolved"); } return Object.freeze({ path: physicalPath, stats: physicalStats }); @@ -935,7 +2078,7 @@ function validateSidecar2(path, stats, maximumBytes) { } function snapshotMembers2(source, maximumBytes) { const current = inspectSource(source.path, maximumBytes); - if (!sameFile2(source.stats, current.stats)) + if (!sameFile3(source.stats, current.stats)) return fail2("changed identity before its snapshot was isolated"); const members = [{ suffix: "", path: current.path, stats: current.stats }]; for (const suffix of ["-wal", "-journal"]) { @@ -959,25 +2102,25 @@ function snapshotMembers2(source, maximumBytes) { function sameSnapshotMembers(left, right) { return left.length === right.length && left.every((member, index) => { const other = right[index]; - return other !== undefined && member.suffix === other.suffix && sameFile2(member.stats, other.stats) && member.stats.size === other.stats.size && member.stats.mtimeNs === other.stats.mtimeNs && member.stats.ctimeNs === other.stats.ctimeNs; + return other !== undefined && member.suffix === other.suffix && sameFile3(member.stats, other.stats) && member.stats.size === other.stats.size && member.stats.mtimeNs === other.stats.mtimeNs && member.stats.ctimeNs === other.stats.ctimeNs; }); } function isolateSource2(source, maximumBytes) { const temporaryRoot = tmpdir2(); - if (!isAbsolute2(temporaryRoot)) + if (!isAbsolute3(temporaryRoot)) return fail2("requires an absolute temporary directory"); - const temporaryDirectory = mkdtempSync2(join2(temporaryRoot, "message-like-me-source-")); + const temporaryDirectory = mkdtempSync2(join3(temporaryRoot, "message-like-me-source-")); chmodSync2(temporaryDirectory, 448); try { for (let attempt = 0;attempt < SOURCE_SNAPSHOT_ATTEMPTS; attempt += 1) { const before = snapshotMembers2(source, maximumBytes); - const attemptDirectory = join2(temporaryDirectory, `attempt-${attempt}`); + const attemptDirectory = join3(temporaryDirectory, `attempt-${attempt}`); mkdirSync2(attemptDirectory, { mode: 448 }); let copyFailedForRace = false; try { for (const member of before) { - const destination = join2(attemptDirectory, `${basename2(source.path)}${member.suffix}`); - copyFileSync2(member.path, destination, fsConstants2.COPYFILE_EXCL | fsConstants2.COPYFILE_FICLONE); + const destination = join3(attemptDirectory, `${basename2(source.path)}${member.suffix}`); + copyFileSync2(member.path, destination, fsConstants3.COPYFILE_EXCL | fsConstants3.COPYFILE_FICLONE); chmodSync2(destination, 384); } } catch (error) { @@ -991,7 +2134,7 @@ function isolateSource2(source, maximumBytes) { if (!copyFailedForRace && sameSnapshotMembers(before, after)) { return Object.freeze({ source: Object.freeze({ path: source.path, stats: before[0].stats }), - path: join2(attemptDirectory, basename2(source.path)), + path: join3(attemptDirectory, basename2(source.path)), temporaryDirectory }); } @@ -1260,7 +2403,7 @@ function loadHandles(database, key) { rowId, id, service, - participantId: hmac2(key, "participant", `${service ?? ""}\x00${id}`) + participantId: hmac3(key, "participant", `${service ?? ""}\x00${id}`) })); } return result; @@ -1296,7 +2439,7 @@ function loadChats(database, schema, handles, key) { const participants = [...handleIds.get(rowId) ?? new Set].sort((left, right) => left - right).map((handleId) => handles.get(handleId)).filter((handle) => handle !== undefined); const services = [...new Set(participants.map((participant) => participant.service).filter((service) => service !== null))].sort(); const conversation = Object.freeze({ - id: hmac2(key, "conversation", sourceKey), + id: hmac3(key, "conversation", sourceKey), sourceKey, privateLabel: privateLabel2, service: declaredService === null || declaredService === "" ? services.length === 1 ? services[0] : null : declaredService, @@ -1429,7 +2572,7 @@ function aggregateWarnings(counts, hasAttachmentJoin) { return Object.freeze(warnings); } function readIMessageDatabase(path, options) { - const key = hmacKey(options.hmacKey); + const key = hmacKey2(options.hmacKey); const maximumDatabaseBytes = boundedInteger2(options.maxDatabaseBytes, DEFAULT_MAX_DATABASE_BYTES, 1, MAX_CONFIGURABLE_DATABASE_BYTES, "maxDatabaseBytes"); const maximumMessages = boundedInteger2(options.maxMessages, DEFAULT_MAX_MESSAGES, 1, MAX_CONFIGURABLE_MESSAGES, "maxMessages"); const maximumBodyBytes = boundedInteger2(options.maxBodyBytes, DEFAULT_MAX_BODY_BYTES, 1, MAX_CONFIGURABLE_BODY_BYTES, "maxBodyBytes"); @@ -1475,7 +2618,7 @@ function readIMessageDatabase(path, options) { const joins = loadChatJoins(database, first, last); const attachments = loadAttachmentCounts(database, schema, first, last); for (const row of page) { - const id = hmac2(key, "message", row.sourceGuid); + const id = hmac3(key, "message", row.sourceGuid); if (messageIds.has(id)) return fail2("contains duplicate message GUIDs"); if (row.isSpam === 1 || row.isCorrupt === 1) { @@ -1580,7 +2723,7 @@ function readIMessageDatabase(path, options) { } // src/metrics.ts -import { createHash as createHash2 } from "crypto"; +import { createHash as createHash3 } from "crypto"; var DEFAULT_SESSION_GAP_SECONDS = 8 * 60 * 60; var DEFAULT_BURST_GAP_SECONDS = 5 * 60; var DEFAULT_STUDY_LIMIT = 12; @@ -1592,8 +2735,8 @@ var MAX_STUDY_MESSAGES_PER_DIRECTION = 64; var DEFAULT_MAX_STUDY_PACKET_BODY_BYTES = 256 * 1024; var MAX_STUDY_PACKET_BODY_BYTES = 1024 * 1024; var MAX_GAP_SECONDS = 30 * 24 * 60 * 60; -function digest(namespace, parts) { - const hash = createHash2("sha256"); +function digest2(namespace, parts) { + const hash = createHash3("sha256"); hash.update(`message-like-me\x00${namespace}\x00`, "utf8"); for (const part of parts) hash.update(`${part.length}:`, "utf8").update(part, "utf8"); @@ -1684,7 +2827,7 @@ function sessionsFor(messages, corpusRevision, contactId, gapSeconds) { const incomingCount = group.filter(({ message }) => message.direction === "incoming").length; const outgoingCount = group.length - incomingCount; return Object.freeze({ - id: digest("session", [corpusRevision, contactId, String(index), ...group.map(({ message }) => message.id)]), + id: digest2("session", [corpusRevision, contactId, String(index), ...group.map(({ message }) => message.id)]), startedAt: first.message.sentAt, endedAt: last.message.sentAt, durationSeconds: round((last.milliseconds - first.milliseconds) / 1000, 3), @@ -1725,7 +2868,7 @@ function burstsFor(messages, sessions, corpusRevision, contactId, burstGapSecond const textBodies = bodies(block.messages); result.push(Object.freeze({ metric: Object.freeze({ - id: digest("burst", [corpusRevision, contactId, session.id, ...messageIds]), + id: digest2("burst", [corpusRevision, contactId, session.id, ...messageIds]), sessionId: session.id, startedAt: first.message.sentAt, endedAt: last.message.sentAt, @@ -1807,7 +2950,7 @@ function responsesFor(bursts, corpusRevision, contactId) { const incomingIds = Object.freeze(incoming.messages.map(({ message }) => message.id)); const outgoingIds = Object.freeze(outgoing.messages.map(({ message }) => message.id)); result.push(Object.freeze({ - id: digest("response", [corpusRevision, contactId, ...incomingIds, "->", ...outgoingIds]), + id: digest2("response", [corpusRevision, contactId, ...incomingIds, "->", ...outgoingIds]), startedAt: incoming.messages[0].message.sentAt, incomingMessageIds: incomingIds, outgoingMessageIds: outgoingIds, @@ -1898,15 +3041,60 @@ function tempoMetrics(messages, responses) { multiQuestionEpisodes: responses.filter((response) => response.incomingQuestions > 1).length }); } -function reactionMetrics(messages) { - const reactions = messages.filter(({ message }) => message.kind === "reaction" && message.retractedAt === null); - const outgoing = reactions.filter(({ message }) => message.direction === "outgoing").length; - const outgoingActions = messages.filter(({ message }) => message.direction === "outgoing" && timelineEligible(message)).length; +function reactionMetrics(messages, facts) { + const legacy = messages.filter(({ message }) => message.kind === "reaction" && message.retractedAt === null).map(({ message }) => ({ + id: message.id, + externalId: message.sourceGuid, + targetExternalId: message.replyToSourceGuid ?? message.sourceGuid, + conversationId: message.conversationId, + direction: message.direction, + body: "unknown", + reactedAt: message.sentAt, + state: "active" + })); + const merged = new Map(legacy.map((fact) => [fact.id, fact])); + for (const fact of facts ?? []) + merged.set(fact.id, fact); + const source = [...merged.values()]; + const ids = new Set; + const reactions = source.filter((fact, index) => { + if (typeof fact.id !== "string" || fact.id.length === 0 || ids.has(fact.id) || fact.direction !== null && fact.direction !== "incoming" && fact.direction !== "outgoing" || typeof fact.body !== "string" || fact.state !== "active" && fact.state !== "removed") + throw new Error(`reactionFacts[${index}] is invalid`); + if (fact.reactedAt !== null) + canonicalTimestamp(fact.reactedAt, `reactionFacts[${index}].reactedAt`); + ids.add(fact.id); + return fact.state === "active"; + }); + const outgoing = reactions.filter(({ direction }) => direction === "outgoing").length; + const incoming = reactions.filter(({ direction }) => direction === "incoming").length; + const unknownDirection = reactions.length - outgoing - incoming; + const outgoingActions = messages.filter(({ message }) => message.kind !== "reaction" && message.direction === "outgoing" && timelineEligible(message)).length + outgoing; + const bodies2 = new Map; + for (const reaction of reactions) { + const counts = bodies2.get(reaction.body) ?? { + total: 0, + incoming: 0, + outgoing: 0, + unknownDirection: 0 + }; + counts.total += 1; + if (reaction.direction === "incoming") + counts.incoming += 1; + else if (reaction.direction === "outgoing") + counts.outgoing += 1; + else + counts.unknownDirection += 1; + bodies2.set(reaction.body, counts); + } return Object.freeze({ total: reactions.length, - incoming: reactions.length - outgoing, + incoming, outgoing, - outgoingReactionRatio: ratio(outgoing, outgoingActions) + unknownDirection, + dated: reactions.filter(({ reactedAt }) => reactedAt !== null).length, + undated: reactions.filter(({ reactedAt }) => reactedAt === null).length, + outgoingReactionRatio: ratio(outgoing, outgoingActions), + byBody: Object.freeze([...bodies2].map(([body, counts]) => Object.freeze({ body, ...counts })).sort((left, right) => right.total - left.total || left.body.localeCompare(right.body, "en-US"))) }); } function analyzeContact(messages, corpusRevision, contactId, options = {}) { @@ -1922,9 +3110,26 @@ function analyzeContact(messages, corpusRevision, contactId, options = {}) { throw new Error("burstGapSeconds cannot exceed sessionGapSeconds"); } const ordered = orderedMessages(messages); - const sessions = sessionsFor(ordered, corpusRevision, contactId, sessionGapSeconds); - const burstRecords = burstsFor(ordered, sessions, corpusRevision, contactId, burstGapSeconds); - const responses = responsesFor(burstRecords, corpusRevision, contactId); + const byConversation = new Map; + for (const row of ordered) { + const rows = byConversation.get(row.message.conversationId) ?? []; + rows.push(row); + byConversation.set(row.message.conversationId, rows); + } + const sessions = []; + const burstRecords = []; + const responses = []; + for (const conversationId of [...byConversation.keys()].sort((left, right) => left.localeCompare(right, "en-US"))) { + const rows = Object.freeze(byConversation.get(conversationId)); + const conversationSessions = sessionsFor(rows, corpusRevision, contactId, sessionGapSeconds); + const conversationBursts = burstsFor(rows, conversationSessions, corpusRevision, contactId, burstGapSeconds); + sessions.push(...conversationSessions); + burstRecords.push(...conversationBursts); + responses.push(...responsesFor(conversationBursts, corpusRevision, contactId)); + } + sessions.sort((left, right) => left.startedAt.localeCompare(right.startedAt, "en-US") || left.id.localeCompare(right.id, "en-US")); + burstRecords.sort((left, right) => left.metric.startedAt.localeCompare(right.metric.startedAt, "en-US") || left.metric.id.localeCompare(right.metric.id, "en-US")); + responses.sort((left, right) => left.startedAt.localeCompare(right.startedAt, "en-US") || left.id.localeCompare(right.id, "en-US")); return Object.freeze({ schemaVersion: METRICS_SCHEMA_VERSION, corpusRevision, @@ -1937,11 +3142,11 @@ function analyzeContact(messages, corpusRevision, contactId, options = {}) { textMessageCount: ordered.filter(({ message }) => message.retractedAt === null && message.kind === "text" && message.body !== null).length, sessionGapSeconds, burstGapSeconds, - sessions, + sessions: Object.freeze(sessions), bursts: Object.freeze(burstRecords.map(({ metric }) => metric)), - responses, + responses: Object.freeze(responses), tempo: tempoMetrics(ordered, responses), - reactions: reactionMetrics(ordered), + reactions: reactionMetrics(ordered, options.reactionFacts), surface: surfaceMetrics(ordered) }); } @@ -2166,7 +3371,11 @@ function aggregateStudyMetrics(metrics) { total: metrics.reactions.total, incoming: metrics.reactions.incoming, outgoing: metrics.reactions.outgoing, - outgoingReactionRatio: metrics.reactions.outgoingReactionRatio + unknownDirection: metrics.reactions.unknownDirection, + dated: metrics.reactions.dated, + undated: metrics.reactions.undated, + outgoingReactionRatio: metrics.reactions.outgoingReactionRatio, + byBody: metrics.reactions.byBody }), surface: Object.freeze({ outgoingTextMessages: metrics.surface.outgoingTextMessages, @@ -2283,7 +3492,7 @@ function buildEvaluationPackets(messages, metrics, options) { emittedBodyBytes += candidate.bodyBytes; } const caseIds = selected.map(({ example }) => example.id); - const evaluationId = digest("evaluation", [ + const evaluationId = digest2("evaluation", [ metrics.corpusRevision, evidenceRevision, metrics.contactId, @@ -2352,46 +3561,46 @@ import { randomBytes } from "crypto"; import { chmod, link, - lstat, + lstat as lstat2, mkdir, - open, + open as open2, readFile, - realpath, + realpath as realpath2, stat, unlink } from "fs/promises"; import { homedir as homedir3, platform } from "os"; -import { basename as basename3, dirname as dirname2, isAbsolute as isAbsolute3, join as join3, resolve as resolve3 } from "path"; +import { basename as basename3, dirname as dirname2, isAbsolute as isAbsolute4, join as join4, resolve as resolve4 } from "path"; function defaultDataDirectory() { const override = process.env.XDG_DATA_HOME; if (override !== undefined && override.trim() !== "") { - if (!isAbsolute3(override)) { + if (!isAbsolute4(override)) { throw new CliError("unsafe-path", "XDG_DATA_HOME must be absolute"); } - return join3(resolve3(override), "message-like-me"); + return join4(resolve4(override), "message-like-me"); } if (platform() === "darwin") { - return join3(homedir3(), "Library", "Application Support", "Message Like Me"); + return join4(homedir3(), "Library", "Application Support", "Message Like Me"); } - return join3(homedir3(), ".local", "share", "message-like-me"); + return join4(homedir3(), ".local", "share", "message-like-me"); } function dataPaths(explicit) { - if (explicit !== undefined && !isAbsolute3(explicit)) { + if (explicit !== undefined && !isAbsolute4(explicit)) { throw new CliError("unsafe-path", "Data directory must be absolute"); } - const root = explicit === undefined ? defaultDataDirectory() : resolve3(explicit); - if (!isAbsolute3(root)) + const root = explicit === undefined ? defaultDataDirectory() : resolve4(explicit); + if (!isAbsolute4(root)) throw new CliError("unsafe-path", "Data directory must be absolute"); return { root, - database: join3(root, "message-like-me.sqlite3"), - installKey: join3(root, "install.key"), - packets: join3(root, "study-packets") + database: join4(root, "message-like-me.sqlite3"), + installKey: join4(root, "install.key"), + packets: join4(root, "study-packets") }; } async function existingType(path) { try { - return await lstat(path); + return await lstat2(path); } catch (error) { if (error.code === "ENOENT") return null; @@ -2414,26 +3623,26 @@ async function ensurePrivateDirectory(path) { throw new CliError("unsafe-path", `${path} must be a directory`); } await mkdir(path, { recursive: true, mode: 448 }); - const after = await lstat(path); + const after = await lstat2(path); if (after.isSymbolicLink() || !after.isDirectory()) { throw new CliError("unsafe-path", `${path} is not a physical directory`); } await assertOwned(path); await chmod(path, 448); - return realpath(path); + return realpath2(path); } async function initializeDataPaths(paths) { const physicalRoot = await ensurePrivateDirectory(paths.root); - const physicalPackets = await ensurePrivateDirectory(join3(physicalRoot, "study-packets")); + const physicalPackets = await ensurePrivateDirectory(join4(physicalRoot, "study-packets")); return { root: physicalRoot, - database: join3(physicalRoot, basename3(paths.database)), - installKey: join3(physicalRoot, basename3(paths.installKey)), + database: join4(physicalRoot, basename3(paths.database)), + installKey: join4(physicalRoot, basename3(paths.installKey)), packets: physicalPackets }; } async function assertPrivateRegularFile(path) { - const metadata = await lstat(path); + const metadata = await lstat2(path); if (metadata.isSymbolicLink() || !metadata.isFile()) { throw new CliError("unsafe-path", `${path} must be a physical regular file`); } @@ -2452,7 +3661,7 @@ async function loadOrCreateInstallKey(path) { } const key = randomBytes(32); try { - const handle = await open(path, "wx", 384); + const handle = await open2(path, "wx", 384); try { await handle.writeFile(`${key.toString("hex")} `, "utf8"); @@ -2471,7 +3680,7 @@ async function loadOrCreateInstallKey(path) { } async function privateOutputDirectory(path) { await mkdir(path, { recursive: true, mode: 448 }); - const requested = await lstat(path); + const requested = await lstat2(path); if (requested.isSymbolicLink() || !requested.isDirectory()) { throw new CliError("unsafe-path", `${path} must be a physical directory`); } @@ -2479,12 +3688,12 @@ async function privateOutputDirectory(path) { if ((requested.mode & 63) !== 0) { throw new CliError("unsafe-path", `${path} must already have private permissions; refusing to change a caller-owned directory`); } - return realpath(path); + return realpath2(path); } async function syncDirectory(path) { let handle = null; try { - handle = await open(path, "r"); + handle = await open2(path, "r"); await handle.sync(); } catch (error) { const code = error.code; @@ -2496,12 +3705,12 @@ async function syncDirectory(path) { } } async function atomicWritePrivate(path, bytes) { - const parent = await privateOutputDirectory(dirname2(resolve3(path))); - const destination = join3(parent, basename3(path)); - const temporary = join3(parent, `.${basename3(path)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`); + const parent = await privateOutputDirectory(dirname2(resolve4(path))); + const destination = join4(parent, basename3(path)); + const temporary = join4(parent, `.${basename3(path)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`); let published = false; try { - const handle = await open(temporary, "wx", 384); + const handle = await open2(temporary, "wx", 384); try { await handle.writeFile(bytes); await handle.chmod(384); @@ -2531,16 +3740,16 @@ async function atomicWritePrivate(path, bytes) { } // src/profile.ts -import { constants as fsConstants3 } from "fs"; -import { open as open2 } from "fs/promises"; +import { constants as fsConstants4 } from "fs"; +import { open as open3 } from "fs/promises"; var MAX_PROFILE_FILE_BYTES = 4 * 1024 * 1024; -function object(value, label) { +function object2(value, label) { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new CliError("invalid-data", `${label} must be an object`); } return value; } -function exactKeys(value, keys, label) { +function exactKeys2(value, keys, label) { const expected = new Set(keys); for (const key of Object.keys(value)) { if (!expected.has(key)) @@ -2579,7 +3788,7 @@ function isoTimestamp(value, label) { function nullableIsoTimestamp(value, label) { return value === null ? null : isoTimestamp(value, label); } -function digest2(value, label) { +function digest3(value, label) { const parsed = text(value, label, 64); if (!/^[a-f0-9]{64}$/u.test(parsed)) { throw new CliError("invalid-data", `${label} must be lowercase SHA-256`); @@ -2599,8 +3808,8 @@ function confidenceLevel(value, label) { return value; } function parseStyleProfileV1(value) { - const root = object(value, "profile"); - exactKeys(root, [ + const root = object2(value, "profile"); + exactKeys2(root, [ "schemaVersion", "contactId", "corpusRevision", @@ -2624,8 +3833,8 @@ function parseStyleProfileV1(value) { if (!/^[a-f0-9]{64}$/u.test(packetSha256)) { throw new CliError("invalid-data", "profile.packetSha256 must be lowercase SHA-256"); } - const prose = object(root.prose, "profile.prose"); - exactKeys(prose, [ + const prose = object2(root.prose, "profile.prose"); + exactKeys2(prose, [ "register", "capitalization", "punctuation", @@ -2636,18 +3845,18 @@ function parseStyleProfileV1(value) { "closings", "notablePatterns" ], "profile.prose"); - const tempo = object(root.tempo, "profile.tempo"); - exactKeys(tempo, [ + const tempo = object2(root.tempo, "profile.tempo"); + exactKeys2(tempo, [ "defaultBundle", "singleLongMessage", "multipleMessages", "responseTiming", "followUps" ], "profile.tempo"); - const replies = object(root.replies, "profile.replies"); - exactKeys(replies, ["usage", "useWhen", "avoidWhen"], "profile.replies"); - const confidence = object(root.confidence, "profile.confidence"); - exactKeys(confidence, ["overall", "limitations"], "profile.confidence"); + const replies = object2(root.replies, "profile.replies"); + exactKeys2(replies, ["usage", "useWhen", "avoidWhen"], "profile.replies"); + const confidence = object2(root.confidence, "profile.confidence"); + exactKeys2(confidence, ["overall", "limitations"], "profile.confidence"); if (!Array.isArray(root.contexts) || root.contexts.length > 32) { throw new CliError("invalid-data", "profile.contexts must contain at most 32 items"); } @@ -2686,8 +3895,8 @@ function parseStyleProfileV1(value) { avoidWhen: textArray(replies.avoidWhen, "profile.replies.avoidWhen") }, contexts: root.contexts.map((item, index) => { - const context = object(item, `profile.contexts[${index}]`); - exactKeys(context, [ + const context = object2(item, `profile.contexts[${index}]`); + exactKeys2(context, [ "when", "incomingPattern", "responseStrategy", @@ -2713,8 +3922,8 @@ function parseStyleProfileV1(value) { }; } function parseStyleProfileV2(value) { - const root = object(value, "profile"); - exactKeys(root, [ + const root = object2(value, "profile"); + exactKeys2(root, [ "schemaVersion", "contactId", "corpusRevision", @@ -2735,10 +3944,10 @@ function parseStyleProfileV2(value) { throw new CliError("invalid-data", `profile.schemaVersion must be ${PROFILE_SCHEMA_VERSION}`); } const contactId = text(root.contactId, "profile.contactId", 128); - const corpusRevision = digest2(root.corpusRevision, "profile.corpusRevision"); - const packetSha256 = digest2(root.packetSha256, "profile.packetSha256"); - const evidence = object(root.evidence, "profile.evidence"); - exactKeys(evidence, [ + const corpusRevision = digest3(root.corpusRevision, "profile.corpusRevision"); + const packetSha256 = digest3(root.packetSha256, "profile.packetSha256"); + const evidence = object2(root.evidence, "profile.evidence"); + exactKeys2(evidence, [ "evidenceRevision", "firstMessageAt", "lastMessageAt", @@ -2763,8 +3972,8 @@ function parseStyleProfileV2(value) { if (after !== null && before !== null && after >= before) { throw new CliError("invalid-data", "profile.evidence.after must be earlier than before"); } - const prose = object(root.prose, "profile.prose"); - exactKeys(prose, [ + const prose = object2(root.prose, "profile.prose"); + exactKeys2(prose, [ "register", "capitalization", "punctuation", @@ -2775,18 +3984,18 @@ function parseStyleProfileV2(value) { "closingPatterns", "notablePatterns" ], "profile.prose"); - const tempo = object(root.tempo, "profile.tempo"); - exactKeys(tempo, [ + const tempo = object2(root.tempo, "profile.tempo"); + exactKeys2(tempo, [ "defaultBundle", "singleLongMessage", "multipleMessages", "responseTiming", "followUps" ], "profile.tempo"); - const replies = object(root.replies, "profile.replies"); - exactKeys(replies, ["usage", "useWhen", "avoidWhen"], "profile.replies"); - const confidence = object(root.confidence, "profile.confidence"); - exactKeys(confidence, [ + const replies = object2(root.replies, "profile.replies"); + exactKeys2(replies, ["usage", "useWhen", "avoidWhen"], "profile.replies"); + const confidence = object2(root.confidence, "profile.confidence"); + exactKeys2(confidence, [ "overall", "prose", "tempo", @@ -2801,8 +4010,8 @@ function parseStyleProfileV2(value) { throw new CliError("invalid-data", "profile.claims must contain at most 64 items"); } const contexts = root.contexts.map((item, index) => { - const context = object(item, `profile.contexts[${index}]`); - exactKeys(context, [ + const context = object2(item, `profile.contexts[${index}]`); + exactKeys2(context, [ "when", "incomingPattern", "responseStrategy", @@ -2820,8 +4029,8 @@ function parseStyleProfileV2(value) { }; }); const claims = root.claims.map((item, index) => { - const claim = object(item, `profile.claims[${index}]`); - exactKeys(claim, [ + const claim = object2(item, `profile.claims[${index}]`); + exactKeys2(claim, [ "dimension", "statement", "basis", @@ -2856,7 +4065,7 @@ function parseStyleProfileV2(value) { packetSha256, analyzedAt: isoTimestamp(root.analyzedAt, "profile.analyzedAt"), evidence: { - evidenceRevision: digest2(evidence.evidenceRevision, "profile.evidence.evidenceRevision"), + evidenceRevision: digest3(evidence.evidenceRevision, "profile.evidence.evidenceRevision"), firstMessageAt, lastMessageAt, messageCount: nonNegativeInteger(evidence.messageCount, "profile.evidence.messageCount"), @@ -2906,7 +4115,7 @@ function parseStyleProfileV2(value) { }; } function parseStyleProfile(value) { - const root = object(value, "profile"); + const root = object2(value, "profile"); if (root.schemaVersion === LEGACY_PROFILE_SCHEMA_VERSION) return parseStyleProfileV1(root); if (root.schemaVersion === PROFILE_SCHEMA_VERSION) @@ -2916,7 +4125,7 @@ function parseStyleProfile(value) { async function readStyleProfile(path) { let parsed; try { - const handle = await open2(path, fsConstants3.O_RDONLY | fsConstants3.O_NOFOLLOW); + const handle = await open3(path, fsConstants4.O_RDONLY | fsConstants4.O_NOFOLLOW); try { const before = await handle.stat(); const privateMode = (before.mode & 63) === 0; @@ -2957,13 +4166,13 @@ async function readStyleProfile(path) { } // src/skill-install.ts -import { cp, lstat as lstat2, mkdir as mkdir2, realpath as realpath2, rm } from "fs/promises"; +import { cp, lstat as lstat3, mkdir as mkdir2, realpath as realpath3, rm } from "fs/promises"; import { homedir as homedir4 } from "os"; -import { dirname as dirname3, join as join4, resolve as resolve4 } from "path"; +import { dirname as dirname3, join as join5, resolve as resolve5 } from "path"; import { fileURLToPath } from "url"; async function exists(path) { try { - await lstat2(path); + await lstat3(path); return true; } catch (error) { if (error.code === "ENOENT") @@ -2972,25 +4181,25 @@ async function exists(path) { } } function bundledSkillPath() { - return resolve4(dirname3(fileURLToPath(import.meta.url)), "../skills/message-like-me"); + return resolve5(dirname3(fileURLToPath(import.meta.url)), "../skills/message-like-me"); } function targetRoot(target, scope, projectDirectory) { const directory = target === "codex" ? ".codex" : target === "claude" ? ".claude" : ".agents"; - return scope === "user" ? join4(homedir4(), directory, "skills") : join4(resolve4(projectDirectory), directory, "skills"); + return scope === "user" ? join5(homedir4(), directory, "skills") : join5(resolve5(projectDirectory), directory, "skills"); } async function installSkill(options) { const source = bundledSkillPath(); if (!await exists(source)) throw new CliError("not-found", `Bundled skill is missing at ${source}`); - const sourceMetadata = await lstat2(source); + const sourceMetadata = await lstat3(source); if (sourceMetadata.isSymbolicLink() || !sourceMetadata.isDirectory()) { throw new CliError("unsafe-path", "Bundled skill must be a physical directory"); } const root = targetRoot(options.target, options.scope, options.projectDirectory ?? process.cwd()); await mkdir2(root, { recursive: true, mode: 448 }); - const destination = join4(root, "message-like-me"); + const destination = join5(root, "message-like-me"); if (await exists(destination)) { - const metadata = await lstat2(destination); + const metadata = await lstat3(destination); if (metadata.isSymbolicLink()) { throw new CliError("unsafe-path", `Refusing to replace symbolic link ${destination}`); } @@ -3000,27 +4209,46 @@ async function installSkill(options) { await rm(destination, { recursive: true, force: true }); } await cp(source, destination, { recursive: true, errorOnExist: true }); - return realpath2(destination); + return realpath3(destination); } // src/store.ts import { Database as Database3 } from "bun:sqlite"; +import { createHash as createHash4 } from "crypto"; import { closeSync, - constants as fsConstants4, + constants as fsConstants5, fchmodSync, fstatSync, lstatSync as lstatSync3, openSync } from "fs"; -var STORE_SCHEMA_VERSION = 2; +var STORE_SCHEMA_VERSION = 3; var PERSON_SCOPE_PREFIX = "person_"; +var IMESSAGE_SOURCE_ID = "source_imessage_local"; var SCHEMA = ` PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS metadata ( key TEXT PRIMARY KEY, value TEXT NOT NULL ) STRICT; + CREATE TABLE IF NOT EXISTS corpus_sources ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('imessage', 'bundle')), + provider TEXT NOT NULL, + network TEXT, + account_id TEXT, + external_id TEXT NOT NULL, + input_revision TEXT NOT NULL, + revision TEXT NOT NULL, + generated_at TEXT, + producer_json TEXT NOT NULL, + coverage_json TEXT NOT NULL, + manifest_sha256 TEXT, + identity_json TEXT NOT NULL, + warnings_json TEXT NOT NULL, + ingested_at TEXT NOT NULL + ) STRICT; CREATE TABLE IF NOT EXISTS conversations ( id TEXT PRIMARY KEY, source_key TEXT NOT NULL, @@ -3031,6 +4259,15 @@ var SCHEMA = ` private_participants_json TEXT NOT NULL, is_group INTEGER NOT NULL CHECK (is_group IN (0, 1)) ) STRICT; + CREATE TABLE IF NOT EXISTS conversation_sources ( + conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE, + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE RESTRICT, + external_id TEXT NOT NULL, + metadata_json TEXT NOT NULL, + UNIQUE (source_id, external_id) + ) STRICT; + CREATE INDEX IF NOT EXISTS conversation_sources_lookup + ON conversation_sources(source_id, conversation_id); CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, source_row_id INTEGER NOT NULL, @@ -3051,6 +4288,54 @@ var SCHEMA = ` CREATE INDEX IF NOT EXISTS messages_conversation_time ON messages(conversation_id, sent_at, source_row_id, id); CREATE INDEX IF NOT EXISTS messages_source_guid ON messages(source_guid); + CREATE TABLE IF NOT EXISTS message_provenance ( + message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE RESTRICT, + external_id TEXT NOT NULL, + reply_to_external_id TEXT, + attachments_json TEXT NOT NULL, + metadata_json TEXT NOT NULL, + UNIQUE (source_id, external_id) + ) STRICT; + CREATE INDEX IF NOT EXISTS message_provenance_source + ON message_provenance(source_id, message_id); + CREATE TABLE IF NOT EXISTS corpus_reaction_facts ( + id TEXT PRIMARY KEY, + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE CASCADE, + external_id TEXT NOT NULL, + target_external_id TEXT NOT NULL, + conversation_id TEXT REFERENCES conversations(id) ON DELETE SET NULL, + direction TEXT CHECK (direction IN ('incoming','outgoing')), + body TEXT NOT NULL, + reacted_at TEXT, + state TEXT NOT NULL CHECK (state IN ('active','removed')), + UNIQUE (source_id, external_id) + ) STRICT; + CREATE INDEX IF NOT EXISTS corpus_reaction_facts_source + ON corpus_reaction_facts(source_id,conversation_id,id); + CREATE TABLE IF NOT EXISTS corpus_source_records ( + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK ( + kind IN ('account','participant','reaction','tombstone','excluded-message') + ), + external_id TEXT NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (source_id, kind, external_id) + ) WITHOUT ROWID, STRICT; + CREATE TABLE IF NOT EXISTS corpus_source_suppressions ( + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK ( + kind IN ('conversation','message','reaction','reaction-timeline','participant','account') + ), + local_id TEXT NOT NULL, + external_id TEXT NOT NULL, + suppressed_at TEXT NOT NULL, + reason TEXT NOT NULL CHECK ( + reason IN ('authoritative-absence','tombstone','explicit-exclusion','replacement','reappeared') + ), + suppressed INTEGER NOT NULL CHECK (suppressed IN (0,1)), + PRIMARY KEY (source_id, kind, local_id) + ) WITHOUT ROWID, STRICT; CREATE TABLE IF NOT EXISTS study_packets ( sha256 TEXT PRIMARY KEY, contact_id TEXT NOT NULL, @@ -3107,6 +4392,82 @@ var SCHEMA = ` CREATE INDEX IF NOT EXISTS conversation_contact_labels_lookup ON conversation_contact_labels(normalized_label, conversation_id); `; +var SOURCE_SCHEMA = ` + CREATE TABLE IF NOT EXISTS corpus_sources ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('imessage', 'bundle')), + provider TEXT NOT NULL, + network TEXT, + account_id TEXT, + external_id TEXT NOT NULL, + input_revision TEXT NOT NULL, + revision TEXT NOT NULL, + generated_at TEXT, + producer_json TEXT NOT NULL, + coverage_json TEXT NOT NULL, + manifest_sha256 TEXT, + identity_json TEXT NOT NULL, + warnings_json TEXT NOT NULL, + ingested_at TEXT NOT NULL + ) STRICT; + CREATE TABLE IF NOT EXISTS conversation_sources ( + conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE, + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE RESTRICT, + external_id TEXT NOT NULL, + metadata_json TEXT NOT NULL, + UNIQUE (source_id, external_id) + ) STRICT; + CREATE INDEX IF NOT EXISTS conversation_sources_lookup + ON conversation_sources(source_id, conversation_id); + CREATE TABLE IF NOT EXISTS message_provenance ( + message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE RESTRICT, + external_id TEXT NOT NULL, + reply_to_external_id TEXT, + attachments_json TEXT NOT NULL, + metadata_json TEXT NOT NULL, + UNIQUE (source_id, external_id) + ) STRICT; + CREATE INDEX IF NOT EXISTS message_provenance_source + ON message_provenance(source_id, message_id); + CREATE TABLE IF NOT EXISTS corpus_reaction_facts ( + id TEXT PRIMARY KEY, + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE CASCADE, + external_id TEXT NOT NULL, + target_external_id TEXT NOT NULL, + conversation_id TEXT REFERENCES conversations(id) ON DELETE SET NULL, + direction TEXT CHECK (direction IN ('incoming','outgoing')), + body TEXT NOT NULL, + reacted_at TEXT, + state TEXT NOT NULL CHECK (state IN ('active','removed')), + UNIQUE (source_id, external_id) + ) STRICT; + CREATE INDEX IF NOT EXISTS corpus_reaction_facts_source + ON corpus_reaction_facts(source_id,conversation_id,id); + CREATE TABLE IF NOT EXISTS corpus_source_records ( + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK ( + kind IN ('account','participant','reaction','tombstone','excluded-message') + ), + external_id TEXT NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (source_id, kind, external_id) + ) WITHOUT ROWID, STRICT; + CREATE TABLE IF NOT EXISTS corpus_source_suppressions ( + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK ( + kind IN ('conversation','message','reaction','reaction-timeline','participant','account') + ), + local_id TEXT NOT NULL, + external_id TEXT NOT NULL, + suppressed_at TEXT NOT NULL, + reason TEXT NOT NULL CHECK ( + reason IN ('authoritative-absence','tombstone','explicit-exclusion','replacement','reappeared') + ), + suppressed INTEGER NOT NULL CHECK (suppressed IN (0,1)), + PRIMARY KEY (source_id, kind, local_id) + ) WITHOUT ROWID, STRICT; +`; var CONTACT_SCOPE_SCHEMA = ` CREATE TABLE IF NOT EXISTS conversation_contact_scopes ( conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE, @@ -3313,7 +4674,15 @@ function personScope(database, addressBookContactId) { SELECT association.conversation_id FROM conversation_contact_scopes association JOIN conversations conversation ON conversation.id=association.conversation_id + JOIN conversation_sources ownership ON ownership.conversation_id=conversation.id WHERE association.contact_id=? AND conversation.is_group=0 + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=ownership.source_id + AND suppression.kind='conversation' + AND suppression.local_id=conversation.id + AND suppression.suppressed=1 + ) ORDER BY association.conversation_id `, addressBookContactId); if (rows.length === 0) @@ -3332,7 +4701,15 @@ function analysisScope(database, contactId) { return personScope(database, addressBookContactId); } } - const conversation = get(database, "SELECT id FROM conversations WHERE id=?", contactId); + const conversation = get(database, `SELECT conversation.id FROM conversations conversation + JOIN conversation_sources ownership ON ownership.conversation_id=conversation.id + WHERE conversation.id=? AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=ownership.source_id + AND suppression.kind='conversation' + AND suppression.local_id=conversation.id + AND suppression.suppressed=1 + )`, contactId); if (conversation === null) return null; const matched = get(database, ` @@ -3350,27 +4727,53 @@ function analysisScope(database, contactId) { function messageRowsForScope(database, scope, exactConversationId, window = UNBOUNDED_EVIDENCE_WINDOW) { if (exactConversationId !== undefined) { return all(database, ` - SELECT * FROM messages WHERE conversation_id=? - AND (? IS NULL OR sent_at>=?) AND (? IS NULL OR sent_at=?) AND (? IS NULL OR message.sent_at=?) AND (? IS NULL OR message.sent_at=?) AND (? IS NULL OR sent_at=?) AND (? IS NULL OR message.sent_at row.reacted_at === null ? window.after === null && window.before === null : (window.after === null || row.reacted_at >= window.after) && (window.before === null || row.reacted_at < window.before)).map((row) => ({ + id: row.id, + externalId: row.external_id, + targetExternalId: row.target_external_id, + conversationId: row.conversation_id, + direction: row.direction, + body: row.body, + reactedAt: row.reacted_at, + state: row.state + })); +} function scopeEvidenceRevision(database, scope, exactConversationId, window = UNBOUNDED_EVIDENCE_WINDOW) { const conversationIds = exactConversationId === undefined ? scope.conversationIds : Object.freeze([exactConversationId]); const messages = messageRowsForScope(database, scope, exactConversationId, window).map(corpusMessage); - return sha256(canonicalJson(window.after === null && window.before === null ? { + const reactions = reactionFactsForScope(database, scope, window); + return sha256(canonicalJson(reactions.length > 0 ? { + schemaVersion: 3, + scopeId: scope.id, + conversationIds, + evidenceWindow: window, + messages, + reactions + } : window.after === null && window.before === null ? { schemaVersion: 1, scopeId: scope.id, conversationIds, @@ -3422,9 +4862,25 @@ function scopeMessageCounts(database, scope) { coalesce(sum(CASE WHEN message.direction='outgoing' THEN 1 ELSE 0 END),0) AS outgoing_count`; const row = scope.kind === "person" ? get(database, `${select} FROM messages message + JOIN message_provenance provenance ON provenance.message_id=message.id JOIN conversation_contact_scopes association ON association.conversation_id=message.conversation_id - WHERE association.contact_id=?`, scope.addressBookContactId) : get(database, `${select} FROM messages message WHERE message.conversation_id=?`, scope.conversationIds[0]); + WHERE association.contact_id=? AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=provenance.source_id + AND suppression.local_id=message.id + AND suppression.kind IN ('message','reaction','reaction-timeline') + AND suppression.suppressed=1 + )`, scope.addressBookContactId) : get(database, `${select} + FROM messages message + JOIN message_provenance provenance ON provenance.message_id=message.id + WHERE message.conversation_id=? AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=provenance.source_id + AND suppression.local_id=message.id + AND suppression.kind IN ('message','reaction','reaction-timeline') + AND suppression.suppressed=1 + )`, scope.conversationIds[0]); return { firstMessageAt: row?.first_message_at ?? null, lastMessageAt: row?.last_message_at ?? null, @@ -3463,11 +4919,49 @@ function backfillLegacyEvidence(database) { } } } +function backfillLegacySource(database) { + const conversations = get(database, "SELECT count(*) AS value FROM conversations")?.value ?? 0; + const assigned = get(database, "SELECT count(*) AS value FROM conversation_sources")?.value ?? 0; + if (assigned !== 0 && assigned !== conversations) { + throw new CliError("invalid-data", "Local store has partially assigned corpus source ownership"); + } + if (conversations === 0 || assigned === conversations) + return; + const revision = scalarText(database, "corpus_revision"); + if (revision === null || !/^[a-f0-9]{64}$/u.test(revision)) { + throw new CliError("invalid-data", "Legacy local store has no valid corpus revision"); + } + const identity = scalarText(database, "source_identity") ?? canonicalJson({ migrated: true }); + const warnings = scalarText(database, "warnings") ?? canonicalJson([]); + const ingestedAt = scalarText(database, "ingested_at") ?? "1970-01-01T00:00:00.000Z"; + database.query(` + INSERT INTO corpus_sources( + id,kind,provider,network,account_id,external_id,input_revision,revision,generated_at, + producer_json,coverage_json,manifest_sha256,identity_json,warnings_json,ingested_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + `).run(IMESSAGE_SOURCE_ID, "imessage", "apple", null, null, "local-imessage", revision, revision, null, canonicalJson({ id: "message-like-me", version: "legacy" }), canonicalJson({ history: "complete-current-local", observedFrom: null, observedTo: null }), null, identity, warnings, ingestedAt); + database.exec(` + INSERT INTO conversation_sources(conversation_id,source_id,external_id,metadata_json) + SELECT id,'${IMESSAGE_SOURCE_ID}',source_key,'{}' FROM conversations; + `); + const rows = all(database, ` + SELECT id,source_guid,reply_to_source_guid,attachment_count + FROM messages ORDER BY id + `); + const insert = database.query(` + INSERT INTO message_provenance( + message_id,source_id,external_id,reply_to_external_id,attachments_json,metadata_json + ) VALUES (?,?,?,?,?,?) + `); + for (const row of rows) { + insert.run(row.id, IMESSAGE_SOURCE_ID, row.source_guid, row.reply_to_source_guid, canonicalJson({ count: row.attachment_count, detailsAvailable: false }), canonicalJson({ migrated: true })); + } +} function initializeStoreSchema(database) { const existingStore = tableExists(database, "metadata"); - const version = userVersion(database); - if (version > STORE_SCHEMA_VERSION) { - throw new CliError("invalid-data", `Local store schema ${version} is newer than supported schema ${STORE_SCHEMA_VERSION}`); + const version2 = userVersion(database); + if (version2 > STORE_SCHEMA_VERSION) { + throw new CliError("invalid-data", `Local store schema ${version2} is newer than supported schema ${STORE_SCHEMA_VERSION}`); } if (!existingStore) { database.exec(SCHEMA); @@ -3479,6 +4973,7 @@ function initializeStoreSchema(database) { throw new CliError("invalid-data", `Local store is missing required table ${table}`); } } + database.exec(SOURCE_SCHEMA); transaction(database, () => { database.exec(CONTACT_SCOPE_SCHEMA); database.exec(` @@ -3494,12 +4989,13 @@ function initializeStoreSchema(database) { addColumn(database, "study_packets", "evidence_json TEXT"); addColumn(database, "profiles", "scope_id TEXT"); addColumn(database, "profiles", "evidence_revision TEXT"); + backfillLegacySource(database); backfillLegacyEvidence(database); database.exec(`PRAGMA user_version=${STORE_SCHEMA_VERSION}`); }); database.exec(SCHEMA); } -function rebuildConversationLabels(database, hmacKey2) { +function rebuildConversationLabels(database, hmacKey3) { database.exec("DELETE FROM conversation_contact_labels; DELETE FROM conversation_contact_scopes;"); const contacts = new Map(all(database, `SELECT id,private_label,normalized_label,label_basis,contacts_revision FROM addressbook_contacts ORDER BY id`).map((row) => [row.id, row])); @@ -3512,7 +5008,17 @@ function rebuildConversationLabels(database, hmacKey2) { owners.set(key, values); } const conversations = all(database, ` - SELECT id,private_participants_json FROM conversations WHERE is_group=0 ORDER BY id + SELECT conversation.id,conversation.private_participants_json + FROM conversations conversation + JOIN conversation_sources ownership ON ownership.conversation_id=conversation.id + WHERE conversation.is_group=0 AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=ownership.source_id + AND suppression.kind='conversation' + AND suppression.local_id=conversation.id + AND suppression.suppressed=1 + ) + ORDER BY conversation.id `); const insertScope = database.query(`INSERT INTO conversation_contact_scopes( conversation_id,contact_id,contacts_revision @@ -3528,10 +5034,10 @@ function rebuildConversationLabels(database, hmacKey2) { let matchedWithoutLabel = 0; for (const conversation of conversations) { const normalizedHandles = stringArray(conversation.private_participants_json, `conversation ${conversation.id} participants`).map(normalizeContactHandle).filter((handle) => handle !== null); - if (normalizedHandles.length > 0 && owners.size > 0 && hmacKey2 === undefined) { + if (normalizedHandles.length > 0 && owners.size > 0 && hmacKey3 === undefined) { throw new CliError("internal", "The installation key is required to rebuild contact labels"); } - const keys = hmacKey2 === undefined ? new Set : new Set(normalizedHandles.map((handle) => `${handle.kind}\x00${contactHandleMatchId(hmacKey2, handle)}`)); + const keys = hmacKey3 === undefined ? new Set : new Set(normalizedHandles.map((handle) => `${handle.kind}\x00${contactHandleMatchId(hmacKey3, handle)}`)); if (keys.size > 0) eligibleConversations += 1; const candidates = new Set; @@ -3597,7 +5103,7 @@ function hardenDatabaseFiles(path) { ]) { let descriptor; try { - descriptor = openSync(candidate, fsConstants4.O_RDONLY | fsConstants4.O_NOFOLLOW); + descriptor = openSync(candidate, fsConstants5.O_RDONLY | fsConstants5.O_NOFOLLOW); } catch (error) { const code = error.code; if (!required && code === "ENOENT") @@ -3621,6 +5127,176 @@ function hardenDatabaseFiles(path) { } } } +function globalCorpusRevision(database) { + const sources = all(database, "SELECT id,kind,input_revision,revision FROM corpus_sources ORDER BY id"); + if (sources.length === 0) + return null; + if (sources.length === 1 && sources[0].id === IMESSAGE_SOURCE_ID && sources[0].kind === "imessage") + return sources[0].input_revision; + return sha256(canonicalJson({ + schemaVersion: 1, + sources: sources.map(({ id, kind, revision }) => ({ id, kind, revision })) + })); +} +function sourceStateRevision(database, sourceId) { + const hash = createHash4("sha256"); + hash.update("message-like-me\x00stored-source-state-v1\x00", "utf8"); + const append = (kind, row) => { + const encoded = canonicalJson(row); + hash.update(`${kind.length}:${kind}${encoded.length}:`, "utf8").update(encoded, "utf8"); + }; + const source = get(database, ` + SELECT kind,provider,network,account_id,external_id,producer_json, + coverage_json,warnings_json + FROM corpus_sources WHERE id=? + `, sourceId); + if (source === null) + throw new CliError("internal", `Missing corpus source ${sourceId}`); + append("source", source); + for (const row of database.query(` + SELECT conversation.id,conversation.source_key,conversation.private_label, + conversation.service,conversation.participant_count, + conversation.participant_ids_json,conversation.private_participants_json, + conversation.is_group + FROM conversation_sources ownership + JOIN conversations conversation ON conversation.id=ownership.conversation_id + WHERE ownership.source_id=? + ORDER BY ownership.external_id,conversation.id + `).iterate(sourceId)) + append("conversation", row); + for (const row of database.query(` + SELECT message.id,message.source_row_id,message.source_guid,message.conversation_id, + message.sent_at,message.direction,message.body,message.body_source,message.kind, + message.reply_to_source_guid,message.edited_at,message.retracted_at,message.service, + message.attachment_count,provenance.external_id, + provenance.reply_to_external_id,provenance.attachments_json + FROM message_provenance provenance + JOIN messages message ON message.id=provenance.message_id + WHERE provenance.source_id=? + ORDER BY provenance.external_id,message.id + `).iterate(sourceId)) + append("message", row); + for (const row of database.query(` + SELECT id,external_id,target_external_id,conversation_id,direction,body,reacted_at,state + FROM corpus_reaction_facts WHERE source_id=? ORDER BY external_id,id + `).iterate(sourceId)) + append("reaction-fact", row); + for (const row of database.query(` + SELECT kind,local_id,external_id,reason FROM corpus_source_suppressions + WHERE source_id=? AND suppressed=1 ORDER BY kind,local_id + `).iterate(sourceId)) + append("suppression", row); + return hash.digest("hex"); +} +function setCorpusRevision(database) { + const revision = globalCorpusRevision(database); + if (revision === null) { + database.query("DELETE FROM metadata WHERE key='corpus_revision'").run(); + return null; + } + database.query(` + INSERT INTO metadata(key,value) VALUES ('corpus_revision',?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value + `).run(revision); + return revision; +} +function validSourceDescriptor(source) { + if (source.id !== IMESSAGE_SOURCE_ID && !/^source_[a-f0-9]{64}$/u.test(source.id) || source.kind !== "imessage" && source.kind !== "bundle" || source.provider.length < 1 || Buffer.byteLength(source.provider, "utf8") > 256 || !/^[a-f0-9]{64}$/u.test(source.revision) || source.externalId.length < 1 || Buffer.byteLength(source.externalId, "utf8") > 4096 || source.warnings.length > 130) + throw new CliError("invalid-data", `Corpus source ${source.id} is invalid`); + canonicalTimestampOrNull(source.generatedAt, `Corpus source ${source.id} generatedAt`); + if (source.kind === "bundle" && source.generatedAt === null) { + throw new CliError("invalid-data", `Bundle source ${source.id} requires generatedAt`); + } + canonicalTimestampOrNull(source.coverage.observedFrom, `Corpus source ${source.id} observedFrom`); + canonicalTimestampOrNull(source.coverage.observedTo, `Corpus source ${source.id} observedTo`); + if (source.coverage.observedFrom !== null && source.coverage.observedTo !== null && source.coverage.observedFrom > source.coverage.observedTo) + throw new CliError("invalid-data", `Corpus source ${source.id} has invalid coverage bounds`); + if (source.coverage.history !== "complete-current-local" && source.coverage.history !== "bounded" && source.coverage.history !== "unknown") + throw new CliError("invalid-data", `Corpus source ${source.id} has invalid history coverage`); + if (source.coverage.kind !== undefined && (source.coverage.kind.length < 1 || Buffer.byteLength(source.coverage.kind, "utf8") > 128 || /\p{Cc}/u.test(source.coverage.kind)) || source.coverage.reason !== undefined && source.coverage.reason !== null && (source.coverage.reason.length < 1 || Buffer.byteLength(source.coverage.reason, "utf8") > 128 || /\p{Cc}/u.test(source.coverage.reason))) + throw new CliError("invalid-data", `Corpus source ${source.id} has invalid coverage metadata`); + if (source.manifestSha256 !== null && !/^[a-f0-9]{64}$/u.test(source.manifestSha256)) + throw new CliError("invalid-data", `Corpus source ${source.id} has an invalid manifest digest`); + if (source.producer.id.length < 1 || source.producer.version.length < 1 || Buffer.byteLength(source.producer.id, "utf8") > 256 || Buffer.byteLength(source.producer.version, "utf8") > 256) + throw new CliError("invalid-data", `Corpus source ${source.id} has invalid producer identity`); + for (const warning of source.warnings) { + if (Buffer.byteLength(warning, "utf8") > 1024 || warning.includes("\x00")) { + throw new CliError("invalid-data", `Corpus source ${source.id} has an invalid warning`); + } + } +} +function validateSourceSnapshot(snapshot) { + validSourceDescriptor(snapshot.source); + if (snapshot.conversations.length > 2000000 || snapshot.messages.length > 2000000 || (snapshot.reactionFacts?.length ?? 0) > 2000000 || snapshot.conversationProvenance.length !== snapshot.conversations.length || snapshot.messageProvenance.length !== snapshot.messages.length) + throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} exceeds its result bounds`); + const conversationIds = new Set(snapshot.conversations.map(({ id }) => id)); + if (conversationIds.size !== snapshot.conversations.length) { + throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} repeats conversation IDs`); + } + const conversationProvenance = new Map(snapshot.conversationProvenance.map((value) => [value.conversationId, value])); + if (conversationProvenance.size !== snapshot.conversationProvenance.length || [...conversationIds].some((id) => !conversationProvenance.has(id))) + throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has invalid conversation provenance`); + const externalConversations = new Set; + for (const provenance of snapshot.conversationProvenance) { + if (provenance.externalId.length < 1 || Buffer.byteLength(provenance.externalId, "utf8") > 4096 || externalConversations.has(provenance.externalId)) + throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has invalid external conversation IDs`); + externalConversations.add(provenance.externalId); + } + const messageIds = new Set(snapshot.messages.map(({ id }) => id)); + if (messageIds.size !== snapshot.messages.length) { + throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} repeats message IDs`); + } + for (const message of snapshot.messages) { + if (!conversationIds.has(message.conversationId)) { + throw new CliError("invalid-data", `Message ${message.id} references an unknown conversation`); + } + } + const messageProvenance = new Map(snapshot.messageProvenance.map((value) => [value.messageId, value])); + if (messageProvenance.size !== snapshot.messageProvenance.length || [...messageIds].some((id) => !messageProvenance.has(id))) + throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has invalid message provenance`); + const externalMessages = new Set; + for (const provenance of snapshot.messageProvenance) { + if (provenance.externalId.length < 1 || Buffer.byteLength(provenance.externalId, "utf8") > 4096 || externalMessages.has(provenance.externalId) || provenance.attachments.length > 256) + throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has invalid external message provenance`); + externalMessages.add(provenance.externalId); + } + const auxiliaryIds = new Set; + for (const record of snapshot.auxiliaryRecords ?? []) { + const key = `${record.kind}\x00${record.id}`; + if (!["account", "participant", "reaction", "tombstone", "excluded-message"].includes(record.kind) || record.id.length < 1 || Buffer.byteLength(record.id, "utf8") > 4096 || auxiliaryIds.has(key)) + throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has invalid auxiliary records`); + const encoded = canonicalJson(record.record); + if (typeof encoded !== "string" || Buffer.byteLength(encoded, "utf8") > 2 * 1024 * 1024) { + throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has an oversized auxiliary record`); + } + auxiliaryIds.add(key); + } + const reactionIds = new Set; + const externalReactionIds = new Set; + for (const reaction of snapshot.reactionFacts ?? []) { + if (reaction.id.length < 1 || reaction.externalId.length < 1 || reaction.targetExternalId.length < 1 || Buffer.byteLength(reaction.id, "utf8") > 4096 || Buffer.byteLength(reaction.externalId, "utf8") > 4096 || Buffer.byteLength(reaction.targetExternalId, "utf8") > 4096 || Buffer.byteLength(reaction.body, "utf8") > 8 * 1024 || reactionIds.has(reaction.id) || externalReactionIds.has(reaction.externalId) || reaction.conversationId !== null && !conversationIds.has(reaction.conversationId) || reaction.direction !== null && reaction.direction !== "incoming" && reaction.direction !== "outgoing" || reaction.state !== "active" && reaction.state !== "removed") + throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has invalid reaction facts`); + canonicalTimestampOrNull(reaction.reactedAt, `Corpus source ${snapshot.source.id} reaction time`); + reactionIds.add(reaction.id); + externalReactionIds.add(reaction.externalId); + } + for (const deletion of snapshot.deletions ?? []) { + if (![ + "account", + "participant", + "conversation", + "message", + "reaction", + "reaction-timeline" + ].includes(deletion.entityKind) || deletion.externalId.length < 1 || Buffer.byteLength(deletion.externalId, "utf8") > 4096 || deletion.localEntityId !== null && Buffer.byteLength(deletion.localEntityId, "utf8") > 4096 || deletion.expectedConversationId !== undefined && (deletion.expectedConversationId.length < 1 || Buffer.byteLength(deletion.expectedConversationId, "utf8") > 4096) || deletion.reason !== undefined && ![ + "tombstone", + "explicit-exclusion", + "replacement" + ].includes(deletion.reason)) + throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has an invalid deletion`); + canonicalTimestampOrNull(deletion.deletedAt, `Corpus source ${snapshot.source.id} deletion time`); + } +} class LocalStore { #database; @@ -3652,13 +5328,15 @@ class LocalStore { return scalarText(this.#database, "corpus_revision"); } sourceIdentity() { - const encoded = scalarText(this.#database, "source_identity"); - return encoded === null ? null : JSON.parse(encoded); + const encoded = get(this.#database, ` + SELECT identity_json FROM corpus_sources WHERE id=? + `, IMESSAGE_SOURCE_ID)?.identity_json ?? scalarText(this.#database, "source_identity"); + return encoded === null ? null : parsedJson(encoded, "Stored iMessage source identity"); } contactsRevision() { return scalarText(this.#database, "contacts_revision"); } - enrichContacts(snapshot, ingestedAt, hmacKey2) { + enrichContacts(snapshot, ingestedAt, hmacKey3) { if (snapshot.schemaVersion !== 1 || !/^[a-f0-9]{64}$/u.test(snapshot.snapshotSha256)) { throw new CliError("invalid-data", "The Contacts reader returned an invalid snapshot revision"); } @@ -3686,7 +5364,7 @@ class LocalStore { const handles = new Set; for (const handle of contact.handles) { const canonical = normalizeContactHandle(handle.normalizedValue); - if (canonical === null || canonical.kind !== handle.kind || canonical.normalizedValue !== handle.normalizedValue || handle.matchId !== contactHandleMatchId(hmacKey2, canonical) || !/^[a-f0-9]{64}$/u.test(handle.matchId)) + if (canonical === null || canonical.kind !== handle.kind || canonical.normalizedValue !== handle.normalizedValue || handle.matchId !== contactHandleMatchId(hmacKey3, canonical) || !/^[a-f0-9]{64}$/u.test(handle.matchId)) throw new CliError("invalid-data", "The Contacts reader returned a non-canonical handle"); const key = `${handle.kind}\x00${handle.matchId}`; if (handles.has(key)) { @@ -3714,7 +5392,7 @@ class LocalStore { insertHandle.run(contact.id, handle.kind, handle.matchId); } } - const projection = rebuildConversationLabels(this.#database, hmacKey2); + const projection = rebuildConversationLabels(this.#database, hmacKey3); const setMetadata = this.#database.query(` INSERT INTO metadata (key,value) VALUES (?,?) ON CONFLICT (key) DO UPDATE SET value=excluded.value @@ -3758,65 +5436,442 @@ class LocalStore { privateLabel: row.private_label })); } - replaceCorpus(snapshot, ingestedAt, hmacKey2) { - const corpusRevision = snapshot.source.snapshotSha256; - if (!/^[a-f0-9]{64}$/u.test(corpusRevision)) { - throw new CliError("invalid-data", "The iMessage reader returned an invalid corpus revision"); + replaceSources(snapshots, ingestedAt, hmacKey3) { + canonicalTimestampOrNull(ingestedAt, "Source ingest time"); + if (snapshots.length < 1) { + throw new CliError("invalid-data", "A source replacement must contain at least one source"); } - const conversationIds = new Set(snapshot.conversations.map((conversation) => conversation.id)); - if (conversationIds.size !== snapshot.conversations.length) { - throw new CliError("invalid-data", "The iMessage reader returned duplicate conversation IDs"); - } - const messageIds = new Set; - for (const message of snapshot.messages) { - if (!conversationIds.has(message.conversationId)) { - throw new CliError("invalid-data", `Message ${message.id} references an unknown conversation`); + const sourceIds = new Set; + for (const snapshot of snapshots) { + if (sourceIds.has(snapshot.source.id)) { + throw new CliError("invalid-data", `Source replacement repeats ${snapshot.source.id}`); } - if (messageIds.has(message.id)) - throw new CliError("invalid-data", `Duplicate message ID ${message.id}`); - messageIds.add(message.id); + sourceIds.add(snapshot.source.id); + validateSourceSnapshot(snapshot); } - transaction(this.#database, () => { - this.#database.exec("DELETE FROM messages; DELETE FROM conversations;"); - const insertConversation = this.#database.query(` - INSERT INTO conversations ( - id, source_key, private_label, service, participant_count, - participant_ids_json, private_participants_json, is_group - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + return transaction(this.#database, () => { + const upsertSource = this.#database.query(` + INSERT INTO corpus_sources( + id,kind,provider,network,account_id,external_id,input_revision,revision,generated_at, + producer_json,coverage_json,manifest_sha256,identity_json,warnings_json,ingested_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + kind=excluded.kind,provider=excluded.provider,network=excluded.network, + account_id=excluded.account_id,external_id=excluded.external_id, + input_revision=excluded.input_revision,generated_at=excluded.generated_at, + producer_json=excluded.producer_json,coverage_json=excluded.coverage_json, + manifest_sha256=excluded.manifest_sha256,identity_json=excluded.identity_json, + warnings_json=excluded.warnings_json,ingested_at=excluded.ingested_at `); - for (const conversation of snapshot.conversations) { - insertConversation.run(conversation.id, conversation.sourceKey, conversation.privateLabel, conversation.service, conversation.participantCount, canonicalJson(conversation.participantIds), canonicalJson(conversation.privateParticipants), conversation.group ? 1 : 0); - } - const insertMessage = this.#database.query(` - INSERT INTO messages ( - id, source_row_id, source_guid, conversation_id, sent_at, direction, - body, body_source, kind, reply_to_source_guid, edited_at, retracted_at, - service, attachment_count - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + const upsertConversation = this.#database.query(` + INSERT INTO conversations( + id,source_key,private_label,service,participant_count, + participant_ids_json,private_participants_json,is_group + ) VALUES (?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + source_key=excluded.source_key,private_label=excluded.private_label, + service=excluded.service,participant_count=excluded.participant_count, + participant_ids_json=excluded.participant_ids_json, + private_participants_json=excluded.private_participants_json,is_group=excluded.is_group + `); + const upsertConversationSource = this.#database.query(` + INSERT INTO conversation_sources(conversation_id,source_id,external_id,metadata_json) + VALUES (?,?,?,?) + ON CONFLICT(conversation_id) DO UPDATE SET + external_id=excluded.external_id,metadata_json=excluded.metadata_json + `); + const upsertMessage = this.#database.query(` + INSERT INTO messages( + id,source_row_id,source_guid,conversation_id,sent_at,direction, + body,body_source,kind,reply_to_source_guid,edited_at,retracted_at, + service,attachment_count + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + source_guid=excluded.source_guid,conversation_id=excluded.conversation_id, + sent_at=excluded.sent_at,direction=excluded.direction,body=excluded.body, + body_source=excluded.body_source,kind=excluded.kind, + reply_to_source_guid=excluded.reply_to_source_guid,edited_at=excluded.edited_at, + retracted_at=excluded.retracted_at,service=excluded.service, + attachment_count=excluded.attachment_count + `); + const upsertMessageProvenance = this.#database.query(` + INSERT INTO message_provenance( + message_id,source_id,external_id,reply_to_external_id,attachments_json,metadata_json + ) VALUES (?,?,?,?,?,?) + ON CONFLICT(message_id) DO UPDATE SET + external_id=excluded.external_id,reply_to_external_id=excluded.reply_to_external_id, + attachments_json=excluded.attachments_json,metadata_json=excluded.metadata_json `); - for (const message of snapshot.messages) { - insertMessage.run(message.id, message.sourceRowId, message.sourceGuid, message.conversationId, message.sentAt, message.direction, message.body, message.bodySource, message.kind, message.replyToSourceGuid, message.editedAt, message.retractedAt, message.service, message.attachmentCount); + const upsertReactionFact = this.#database.query(` + INSERT INTO corpus_reaction_facts( + id,source_id,external_id,target_external_id,conversation_id, + direction,body,reacted_at,state + ) VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + external_id=excluded.external_id,target_external_id=excluded.target_external_id, + conversation_id=excluded.conversation_id,direction=excluded.direction, + body=excluded.body,reacted_at=excluded.reacted_at,state=excluded.state + `); + const upsertSourceRecord = this.#database.query(` + INSERT INTO corpus_source_records(source_id,kind,external_id,record_json) + VALUES (?,?,?,?) + ON CONFLICT(source_id,kind,external_id) DO UPDATE SET record_json=excluded.record_json + `); + const setSuppression = this.#database.query(` + INSERT INTO corpus_source_suppressions( + source_id,kind,local_id,external_id,suppressed_at,reason,suppressed + ) VALUES (?,?,?,?,?,?,?) + ON CONFLICT(source_id,kind,local_id) DO UPDATE SET + external_id=excluded.external_id,suppressed_at=excluded.suppressed_at, + reason=excluded.reason,suppressed=excluded.suppressed + `); + const results = []; + let changedAny = false; + for (const snapshot of snapshots) { + const existing = get(this.#database, ` + SELECT kind,input_revision,revision,generated_at,manifest_sha256 + FROM corpus_sources WHERE id=? + `, snapshot.source.id); + if (existing !== null && existing.kind !== snapshot.source.kind) { + throw new CliError("conflict", `Source ${snapshot.source.id} changed kind`); + } + if (existing !== null && snapshot.source.kind === "bundle") { + if (existing.generated_at === null || snapshot.source.generatedAt < existing.generated_at) { + throw new CliError("conflict", `Source ${snapshot.source.id} snapshot is older than stored state`); + } + if (snapshot.source.generatedAt === existing.generated_at && (snapshot.source.revision !== existing.input_revision || snapshot.source.manifestSha256 !== existing.manifest_sha256)) + throw new CliError("conflict", `Source ${snapshot.source.id} reuses generatedAt for different input`); + } + const authoritative = snapshot.source.kind === "imessage" || snapshot.source.coverage.history === "complete-current-local"; + if (authoritative) { + for (const row of this.#database.query(` + SELECT conversation_id,external_id FROM conversation_sources WHERE source_id=? + `).iterate(snapshot.source.id)) { + setSuppression.run(snapshot.source.id, "conversation", row.conversation_id, row.external_id, ingestedAt, "authoritative-absence", 1); + } + for (const row of this.#database.query(` + SELECT id,external_id FROM corpus_reaction_facts WHERE source_id=? + `).iterate(snapshot.source.id)) { + setSuppression.run(snapshot.source.id, "reaction", row.id, row.external_id, ingestedAt, "authoritative-absence", 1); + } + for (const row of this.#database.query(` + SELECT provenance.message_id,provenance.external_id,message.kind + FROM message_provenance provenance + JOIN messages message ON message.id=provenance.message_id + WHERE provenance.source_id=? + `).iterate(snapshot.source.id)) { + setSuppression.run(snapshot.source.id, row.kind === "reaction" ? "reaction" : "message", row.message_id, row.external_id, ingestedAt, "authoritative-absence", 1); + } + } + upsertSource.run(snapshot.source.id, snapshot.source.kind, snapshot.source.provider, snapshot.source.network, snapshot.source.accountId, snapshot.source.externalId, snapshot.source.revision, existing?.revision ?? snapshot.source.revision, snapshot.source.generatedAt, canonicalJson(snapshot.source.producer), canonicalJson(snapshot.source.coverage), snapshot.source.manifestSha256, canonicalJson(snapshot.source.identity), canonicalJson(snapshot.source.warnings), ingestedAt); + const conversationProvenance = new Map(snapshot.conversationProvenance.map((value) => [value.conversationId, value])); + for (const conversation of snapshot.conversations) { + const owner = get(this.#database, ` + SELECT source_id FROM conversation_sources WHERE conversation_id=? + `, conversation.id); + if (owner !== null && owner.source_id !== snapshot.source.id) { + throw new CliError("conflict", `Conversation ${conversation.id} belongs to another source`); + } + upsertConversation.run(conversation.id, conversation.sourceKey, conversation.privateLabel, conversation.service, conversation.participantCount, canonicalJson(conversation.participantIds), canonicalJson(conversation.privateParticipants), conversation.group ? 1 : 0); + const provenance = conversationProvenance.get(conversation.id); + upsertConversationSource.run(conversation.id, snapshot.source.id, provenance.externalId, canonicalJson(provenance.metadata ?? {})); + setSuppression.run(snapshot.source.id, "conversation", conversation.id, provenance.externalId, ingestedAt, "reappeared", 0); + } + const messageProvenance = new Map(snapshot.messageProvenance.map((value) => [value.messageId, value])); + for (const message of snapshot.messages) { + const owner = get(this.#database, ` + SELECT provenance.source_id,message.source_row_id + FROM message_provenance provenance + JOIN messages message ON message.id=provenance.message_id + WHERE provenance.message_id=? + `, message.id); + if (owner !== null && owner.source_id !== snapshot.source.id) { + throw new CliError("conflict", `Message ${message.id} belongs to another source`); + } + const preferredRowId = authoritative ? message.sourceRowId : null; + const preferredCollision = preferredRowId === null ? null : get(this.#database, "SELECT id FROM messages WHERE conversation_id=? AND source_row_id=?", message.conversationId, preferredRowId); + const sourceRowId = owner?.source_row_id ?? (preferredRowId !== null && preferredCollision === null ? preferredRowId : (get(this.#database, ` + SELECT max(source_row_id) AS value FROM messages WHERE conversation_id=? + `, message.conversationId)?.value ?? 0) + 1); + upsertMessage.run(message.id, sourceRowId, message.sourceGuid, message.conversationId, message.sentAt, message.direction, message.body, message.bodySource, message.kind, message.replyToSourceGuid, message.editedAt, message.retractedAt, message.service, message.attachmentCount); + const provenance = messageProvenance.get(message.id); + upsertMessageProvenance.run(message.id, snapshot.source.id, provenance.externalId, provenance.replyToExternalId, canonicalJson(provenance.attachments), canonicalJson(provenance.metadata ?? {})); + setSuppression.run(snapshot.source.id, message.kind === "reaction" ? "reaction" : "message", message.id, provenance.externalId, ingestedAt, "reappeared", 0); + if (message.kind === "reaction") { + setSuppression.run(snapshot.source.id, "reaction-timeline", message.id, provenance.externalId, ingestedAt, "reappeared", 0); + } + } + for (const reaction of snapshot.reactionFacts ?? []) { + const existingReaction = get(this.#database, ` + SELECT source_id,external_id FROM corpus_reaction_facts WHERE id=? + `, reaction.id); + if (existingReaction !== null && (existingReaction.source_id !== snapshot.source.id || existingReaction.external_id !== reaction.externalId)) + throw new CliError("conflict", `Reaction ${reaction.id} belongs to another source coordinate`); + const conversationId = reaction.conversationId ?? get(this.#database, `SELECT message.conversation_id + FROM message_provenance provenance + JOIN messages message ON message.id=provenance.message_id + WHERE provenance.source_id=? AND provenance.external_id=?`, snapshot.source.id, reaction.targetExternalId)?.conversation_id ?? null; + upsertReactionFact.run(reaction.id, snapshot.source.id, reaction.externalId, reaction.targetExternalId, conversationId, reaction.direction, reaction.body, reaction.reactedAt, reaction.state); + if (reaction.state === "active") { + setSuppression.run(snapshot.source.id, "reaction", reaction.id, reaction.externalId, ingestedAt, "reappeared", 0); + } + } + this.#database.query(` + UPDATE corpus_reaction_facts AS reaction + SET conversation_id=( + SELECT message.conversation_id + FROM message_provenance provenance + JOIN messages message ON message.id=provenance.message_id + WHERE provenance.source_id=reaction.source_id + AND provenance.external_id=reaction.target_external_id + ) + WHERE reaction.source_id=? AND reaction.conversation_id IS NULL + AND EXISTS ( + SELECT 1 FROM message_provenance provenance + WHERE provenance.source_id=reaction.source_id + AND provenance.external_id=reaction.target_external_id + ) + `).run(snapshot.source.id); + for (const record of snapshot.auxiliaryRecords ?? []) { + upsertSourceRecord.run(snapshot.source.id, record.kind, record.id, canonicalJson(record.record)); + } + for (const deletion of snapshot.deletions ?? []) { + let localId = deletion.localEntityId; + if (deletion.entityKind === "conversation") { + const specifiedLocal = localId !== null; + const target = localId === null ? get(this.#database, ` + SELECT conversation_id,external_id FROM conversation_sources + WHERE source_id=? AND external_id=? + `, snapshot.source.id, deletion.externalId) : get(this.#database, ` + SELECT conversation_id,external_id FROM conversation_sources + WHERE source_id=? AND conversation_id=? + `, snapshot.source.id, localId); + if (target !== null) { + if (target.external_id !== deletion.externalId) { + throw new CliError("invalid-data", "A conversation deletion has mismatched coordinates"); + } + localId = target.conversation_id; + } else if (specifiedLocal) { + throw new CliError("invalid-data", "A conversation deletion references an unknown local entity"); + } + } + if (deletion.entityKind === "message") { + const specifiedLocal = localId !== null; + const target = localId === null ? get(this.#database, ` + SELECT provenance.message_id,provenance.external_id, + message.conversation_id,message.kind + FROM message_provenance provenance + JOIN messages message ON message.id=provenance.message_id + WHERE provenance.source_id=? AND provenance.external_id=? + `, snapshot.source.id, deletion.externalId) : get(this.#database, ` + SELECT provenance.message_id,provenance.external_id, + message.conversation_id,message.kind + FROM message_provenance provenance + JOIN messages message ON message.id=provenance.message_id + WHERE provenance.source_id=? AND provenance.message_id=? + `, snapshot.source.id, localId); + if (target !== null) { + if (target.external_id !== deletion.externalId || target.kind === "reaction" || deletion.expectedConversationId !== undefined && deletion.expectedConversationId !== target.conversation_id) + throw new CliError("invalid-data", "A message deletion has mismatched coordinates"); + localId = target.message_id; + } else if (specifiedLocal) { + throw new CliError("invalid-data", "A message deletion references an unknown local entity"); + } + } + if (deletion.entityKind === "reaction" || deletion.entityKind === "reaction-timeline") { + const specifiedLocal = localId !== null; + const target = localId === null ? get(this.#database, ` + SELECT id,external_id,conversation_id FROM corpus_reaction_facts + WHERE source_id=? AND external_id=? + `, snapshot.source.id, deletion.externalId) : get(this.#database, ` + SELECT id,external_id,conversation_id FROM corpus_reaction_facts + WHERE source_id=? AND id=? + `, snapshot.source.id, localId); + if (target !== null) { + if (target.external_id !== deletion.externalId || deletion.expectedConversationId !== undefined && deletion.expectedConversationId !== target.conversation_id) + throw new CliError("invalid-data", "A reaction deletion has mismatched coordinates"); + localId = target.id; + } else if (specifiedLocal) { + throw new CliError("invalid-data", "A reaction deletion references an unknown local entity"); + } + } + setSuppression.run(snapshot.source.id, deletion.entityKind, localId ?? `external:${deletion.externalId}`, deletion.externalId, deletion.deletedAt, deletion.reason ?? "tombstone", 1); + } + const stateRevision = sourceStateRevision(this.#database, snapshot.source.id); + this.#database.query("UPDATE corpus_sources SET revision=? WHERE id=?").run(stateRevision, snapshot.source.id); + const changed = existing?.revision !== stateRevision; + changedAny ||= changed; + const counts = get(this.#database, ` + SELECT count(distinct conversation.id) AS conversations, + count(message.id) AS messages + FROM conversation_sources ownership + JOIN conversations conversation ON conversation.id=ownership.conversation_id + LEFT JOIN messages message ON message.conversation_id=conversation.id + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=ownership.source_id + AND suppression.local_id=message.id + AND suppression.kind IN ('message','reaction','reaction-timeline') + AND suppression.suppressed=1 + ) + WHERE ownership.source_id=? + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=ownership.source_id + AND suppression.local_id=conversation.id + AND suppression.kind='conversation' + AND suppression.suppressed=1 + ) + `, snapshot.source.id) ?? { conversations: 0, messages: 0 }; + results.push(Object.freeze({ id: snapshot.source.id, changed, ...counts })); } + if (changedAny) + rebuildConversationLabels(this.#database, hmacKey3); + const corpusRevision = setCorpusRevision(this.#database); + if (corpusRevision === null) + throw new CliError("internal", "Source replacement produced no corpus revision"); + return Object.freeze({ corpusRevision, sources: Object.freeze(results) }); + }); + } + replaceCorpus(snapshot, ingestedAt, hmacKey3) { + if (!/^[a-f0-9]{64}$/u.test(snapshot.source.snapshotSha256)) { + throw new CliError("invalid-data", "The iMessage reader returned an invalid corpus revision"); + } + const observed = snapshot.messages.map(({ sentAt }) => sentAt).sort(); + const sourceSnapshot = Object.freeze({ + source: Object.freeze({ + id: IMESSAGE_SOURCE_ID, + kind: "imessage", + provider: "apple", + network: null, + accountId: null, + externalId: "local-imessage", + revision: snapshot.source.snapshotSha256, + generatedAt: null, + producer: Object.freeze({ id: "message-like-me", version: "imessage-reader-v1" }), + coverage: Object.freeze({ + history: "complete-current-local", + observedFrom: observed[0] ?? null, + observedTo: observed.at(-1) ?? null + }), + manifestSha256: null, + identity: snapshot.source, + warnings: snapshot.warnings + }), + conversations: snapshot.conversations, + conversationProvenance: Object.freeze(snapshot.conversations.map((conversation) => ({ + conversationId: conversation.id, + externalId: conversation.sourceKey + }))), + messages: snapshot.messages, + messageProvenance: Object.freeze(snapshot.messages.map((message) => ({ + messageId: message.id, + externalId: message.sourceGuid, + replyToExternalId: message.replyToSourceGuid, + attachments: Object.freeze(Array.from({ length: message.attachmentCount }, (_value, index) => ({ + id: `unavailable-${index + 1}`, + kind: null, + mimeType: null, + fileName: null, + bytes: null + }))) + }))) + }); + const replaced = this.replaceSources([sourceSnapshot], ingestedAt, hmacKey3); + transaction(this.#database, () => { const setMetadata = this.#database.query(` - INSERT INTO metadata (key, value) VALUES (?, ?) - ON CONFLICT (key) DO UPDATE SET value = excluded.value + INSERT INTO metadata(key,value) VALUES (?,?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value `); for (const [key, value] of [ - ["corpus_revision", corpusRevision], ["source_identity", canonicalJson(snapshot.source)], ["ingested_at", ingestedAt], ["warnings", canonicalJson(snapshot.warnings)], ["corpus_schema_version", String(snapshot.schemaVersion)] ]) setMetadata.run(key, value); - rebuildConversationLabels(this.#database, hmacKey2); }); return { - corpusRevision, + corpusRevision: replaced.corpusRevision, conversations: snapshot.conversations.length, messages: snapshot.messages.length }; } + listSources(privateDetails = false) { + const rows = all(this.#database, ` + SELECT source.*, + count(distinct ownership.conversation_id) AS conversations, + count(message.id) AS messages, + (SELECT count(*) FROM corpus_reaction_facts reaction + WHERE reaction.source_id=source.id AND reaction.state='active' + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id AND suppression.kind='reaction' + AND suppression.local_id=reaction.id AND suppression.suppressed=1 + )) AS reactions, + (SELECT count(*) FROM corpus_reaction_facts reaction + WHERE reaction.source_id=source.id AND reaction.state='active' + AND reaction.reacted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id AND suppression.kind='reaction' + AND suppression.local_id=reaction.id AND suppression.suppressed=1 + )) AS undated_reactions + FROM corpus_sources source + LEFT JOIN conversation_sources ownership ON ownership.source_id=source.id + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id + AND suppression.kind='conversation' + AND suppression.local_id=ownership.conversation_id + AND suppression.suppressed=1 + ) + LEFT JOIN messages message ON message.conversation_id=ownership.conversation_id + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id + AND suppression.kind IN ('message','reaction','reaction-timeline') + AND suppression.local_id=message.id + AND suppression.suppressed=1 + ) + GROUP BY source.id + ORDER BY source.provider,source.network,source.id + `); + return rows.map((row) => { + const warnings = parsedJson(row.warnings_json, `Source ${row.id} warnings`); + if (!Array.isArray(warnings)) + throw new CliError("invalid-data", `Source ${row.id} warnings are invalid`); + return { + id: row.id, + kind: row.kind, + provider: row.provider, + network: row.network, + revision: row.revision, + generatedAt: row.generated_at, + ingestedAt: row.ingested_at, + coverage: parsedJson(row.coverage_json, `Source ${row.id} coverage`), + warningCount: warnings.length, + conversations: row.conversations, + messages: row.messages, + reactions: row.reactions, + undatedReactions: row.undated_reactions, + ...privateDetails ? { + accountId: row.account_id, + externalId: row.external_id, + manifestSha256: row.manifest_sha256, + inputRevision: row.input_revision, + identity: parsedJson(row.identity_json, `Source ${row.id} identity`), + warnings + } : {} + }; + }); + } + source(sourceId, privateDetails = false) { + if (sourceId.length < 1 || sourceId.length > 256) { + throw new CliError("usage", "Source ID must be bounded non-empty text"); + } + return this.listSources(privateDetails).find(({ id }) => id === sourceId) ?? null; + } listContacts(options) { if (this.corpusRevision() === null) return []; @@ -3828,16 +5883,30 @@ class LocalStore { conversation.id AS conversation_id FROM conversation_contact_scopes association JOIN conversations conversation ON conversation.id=association.conversation_id + JOIN conversation_sources ownership ON ownership.conversation_id=conversation.id LEFT JOIN conversation_contact_labels label ON label.conversation_id=association.conversation_id - WHERE conversation.is_group=0 + WHERE conversation.is_group=0 AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=ownership.source_id + AND suppression.kind='conversation' + AND suppression.local_id=conversation.id + AND suppression.suppressed=1 + ) UNION ALL SELECT conversation.id,conversation.private_label,'conversation',conversation.is_group, conversation.participant_count,conversation.id FROM conversations conversation + JOIN conversation_sources ownership ON ownership.conversation_id=conversation.id LEFT JOIN conversation_contact_scopes association ON association.conversation_id=conversation.id - WHERE association.conversation_id IS NULL + WHERE association.conversation_id IS NULL AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=ownership.source_id + AND suppression.kind='conversation' + AND suppression.local_id=conversation.id + AND suppression.suppressed=1 + ) ) SELECT scope.id,min(scope.private_label) AS private_label, max(scope.scope_kind) AS scope_kind, @@ -3849,6 +5918,14 @@ class LocalStore { sum(CASE WHEN message.direction = 'outgoing' THEN 1 ELSE 0 END) AS outgoing_count FROM scope_conversations scope JOIN messages message ON message.conversation_id=scope.conversation_id + JOIN message_provenance provenance ON provenance.message_id=message.id + WHERE NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=provenance.source_id + AND suppression.local_id=message.id + AND suppression.kind IN ('message','reaction','reaction-timeline') + AND suppression.suppressed=1 + ) GROUP BY scope.id HAVING outgoing_count >= ? ORDER BY outgoing_count DESC,last_message_at DESC,scope.id @@ -3930,6 +6007,7 @@ class LocalStore { scopeKind: scope.kind, conversationCount: scope.conversationIds.length, service: services.length === 1 ? services[0] : null, + services: Object.freeze(services.sort((left, right) => left < right ? -1 : left > right ? 1 : 0)), participantCount: scope.kind === "person" ? 1 : first.participant_count, participantIds: participants, privateParticipants, @@ -3954,7 +6032,8 @@ class LocalStore { return { corpusRevision, evidenceRevision: scopeEvidenceRevision(this.#database, scope, undefined, window), - messages: messageRowsForScope(this.#database, scope, undefined, window).map(corpusMessage) + messages: messageRowsForScope(this.#database, scope, undefined, window).map(corpusMessage), + reactions: reactionFactsForScope(this.#database, scope, window) }; }); } @@ -4098,6 +6177,7 @@ class LocalStore { foreignKeyViolations: foreignKeys, corpusRevision: this.corpusRevision(), contactsRevision: this.contactsRevision(), + sources: count("corpus_sources"), conversations: count("conversations"), messages: count("messages"), profiles: count("profiles"), @@ -4108,7 +6188,7 @@ class LocalStore { } // src/version.ts -var MESSAGE_LIKE_ME_VERSION = "0.2.0"; +var MESSAGE_LIKE_ME_VERSION = "0.3.0"; // src/commands.ts var HELP = `Message Like Me ${MESSAGE_LIKE_ME_VERSION} @@ -4116,7 +6196,10 @@ var HELP = `Message Like Me ${MESSAGE_LIKE_ME_VERSION} Usage: messagelikeme [--data-dir PATH] init [--json] messagelikeme [--data-dir PATH] ingest imessage [--database PATH] [--json] + messagelikeme [--data-dir PATH] ingest bundle --input ABS_PATH [--json] messagelikeme [--data-dir PATH] ingest contacts [--addressbook PATH] [--json] + messagelikeme [--data-dir PATH] sources list [--private] [--json] + messagelikeme [--data-dir PATH] sources show SOURCE_ID [--private] [--json] messagelikeme [--data-dir PATH] contacts list [--min-outgoing N] [--limit N] [--private] [--json] messagelikeme [--data-dir PATH] contacts show CONTACT_ID [--private] [--json] messagelikeme [--data-dir PATH] contacts resolve QUERY --private [--limit N] [--json] @@ -4137,13 +6220,13 @@ Usage: [--project PATH] [--force] [--json] messagelikeme [--data-dir PATH] doctor [--json] -Message Like Me reads caller-owned macOS Messages and optional Contacts data, -then stores private analysis locally. It has no network, account, AI-provider, -or message-sending surface. +Message Like Me reads caller-owned macOS Messages, optional Contacts data, and +strict private local message bundles, then stores private analysis locally. It +has no network, account, AI-provider, or message-sending surface. `; async function exists2(path) { try { - await lstat3(path); + await lstat4(path); return true; } catch (error) { if (error.code === "ENOENT") @@ -4196,7 +6279,10 @@ function contactEvidence(store, contactId, window) { } function contactMetrics(store, contactId, options = {}) { const evidence = contactEvidence(store, contactId); - return analyzeContact(evidence.messages, evidence.corpusRevision, contactId, options); + return analyzeContact(evidence.messages, evidence.corpusRevision, contactId, { + ...options, + reactionFacts: evidence.reactions + }); } function metricOptions(parsed) { return { @@ -4228,6 +6314,7 @@ function safeContactDetail(store, contactId, privateLabels) { privateParticipants: conversation.privateParticipants } : {}, service: conversation.service, + services: conversation.services, group: conversation.group, participantCount: conversation.participantCount, participantIds: conversation.participantIds, @@ -4261,9 +6348,9 @@ function compactMetrics(metrics) { function absolutePrivatePath(value, label) { if (value === undefined) throw new CliError("usage", `${label} is required`); - if (!isAbsolute4(value)) + if (!isAbsolute5(value)) throw new CliError("unsafe-path", `${label} must be an absolute private path`); - return resolve5(value); + return resolve6(value); } function translateIMessageError(error) { const code = error.code; @@ -4286,6 +6373,18 @@ function translateContactsError(error) { const message = error instanceof Error ? error.message : ""; throw new CliError("invalid-data", message.startsWith("Contacts source ") ? message : "The selected AddressBook source could not be read safely", { cause: error }); } +function translateBundleError(error) { + if (error instanceof CliError) + throw error; + const code = error.code; + if (code === "EACCES" || code === "EPERM") { + throw new CliError("permission", "The selected private bundle is not readable", { cause: error }); + } + if (code === "ENOENT") { + throw new CliError("not-found", "The selected private bundle does not exist", { cause: error }); + } + throw new CliError("invalid-data", "The selected private message bundle could not be read safely", { cause: error }); +} async function runCommand(argv, io) { const parsed = parseArguments(argv); if (parsed.flags.has("version")) { @@ -4303,7 +6402,7 @@ async function runCommand(argv, io) { return; } const json = parsed.flags.has("json"); - const [command, subcommand, identifier, ...extra] = parsed.positionals; + const [command, subcommand, identifier2, ...extra] = parsed.positionals; if (extra.length !== 0) throw new CliError("usage", `Unexpected argument ${extra[0]}`); if (command === "init" && subcommand === undefined) { @@ -4317,7 +6416,7 @@ async function runCommand(argv, io) { } return; } - if (command === "ingest" && subcommand === "imessage" && identifier === undefined) { + if (command === "ingest" && subcommand === "imessage" && identifier2 === undefined) { rejectUnused(parsed, ["data-dir", "database"], ["json"]); const context = await writableStore(parsed); try { @@ -4345,7 +6444,33 @@ async function runCommand(argv, io) { } return; } - if (command === "ingest" && subcommand === "contacts" && identifier === undefined) { + if (command === "ingest" && subcommand === "bundle" && identifier2 === undefined) { + rejectUnused(parsed, ["data-dir", "input"], ["json"]); + const input = absolutePrivatePath(parsed.options.get("input"), "--input"); + const context = await writableStore(parsed); + try { + let bundle; + try { + bundle = await readMessageBundle(input, { hmacKey: context.key }); + } catch (error) { + translateBundleError(error); + } + const stored = context.store.replaceSources(bundle.sources, canonicalNow(io), context.key); + const result = { + schemaVersion: bundle.schemaVersion, + manifestSha256: bundle.manifestSha256, + corpusRevision: stored.corpusRevision, + sources: stored.sources, + conversations: stored.sources.reduce((sum, source) => sum + source.conversations, 0), + messages: stored.sources.reduce((sum, source) => sum + source.messages, 0) + }; + emit(io, json, result, `Ingested ${result.messages} active messages across ${result.conversations} conversations from ${result.sources.length} sources`); + } finally { + context.store.close(); + } + return; + } + if (command === "ingest" && subcommand === "contacts" && identifier2 === undefined) { rejectUnused(parsed, ["data-dir", "addressbook"], ["json"]); const context = await writableStore(parsed); try { @@ -4373,7 +6498,7 @@ async function runCommand(argv, io) { } return; } - if (command === "contacts" && subcommand === "list" && identifier === undefined) { + if (command === "contacts" && subcommand === "list" && identifier2 === undefined) { rejectUnused(parsed, ["data-dir", "min-outgoing", "limit"], ["json", "private"]); const context = await existingStore(parsed); try { @@ -4388,18 +6513,42 @@ async function runCommand(argv, io) { } return; } - if (command === "contacts" && subcommand === "show" && identifier !== undefined) { + if (command === "sources" && subcommand === "list" && identifier2 === undefined) { + rejectUnused(parsed, ["data-dir"], ["json", "private"]); + const context = await existingStore(parsed); + try { + const sources = context.store.listSources(parsed.flags.has("private")); + emit(io, json, { sources }, `${sources.length} message sources`); + } finally { + context.store.close(); + } + return; + } + if (command === "sources" && subcommand === "show" && identifier2 !== undefined) { + rejectUnused(parsed, ["data-dir"], ["json", "private"]); + const context = await existingStore(parsed); + try { + const source = context.store.source(identifier2, parsed.flags.has("private")); + if (source === null) + throw new CliError("not-found", `Unknown source ${identifier2}`); + emit(io, json, source, `Message source ${identifier2}`); + } finally { + context.store.close(); + } + return; + } + if (command === "contacts" && subcommand === "show" && identifier2 !== undefined) { rejectUnused(parsed, ["data-dir"], ["json", "private"]); const context = await existingStore(parsed); try { - const detail = safeContactDetail(context.store, identifier, parsed.flags.has("private")); - emit(io, json, detail, `Contact ${identifier}`); + const detail = safeContactDetail(context.store, identifier2, parsed.flags.has("private")); + emit(io, json, detail, `Contact ${identifier2}`); } finally { context.store.close(); } return; } - if (command === "contacts" && subcommand === "resolve" && identifier !== undefined) { + if (command === "contacts" && subcommand === "resolve" && identifier2 !== undefined) { rejectUnused(parsed, ["data-dir", "limit"], ["json", "private"]); if (!parsed.flags.has("private")) { throw new CliError("usage", "contacts resolve requires --private"); @@ -4408,7 +6557,7 @@ async function runCommand(argv, io) { try { let matches; try { - matches = context.store.resolvePrivateContacts(identifier, integerOption(parsed, "limit", 10, 1, 50)); + matches = context.store.resolvePrivateContacts(identifier2, integerOption(parsed, "limit", 10, 1, 50)); } catch (error) { if (error instanceof CliError) throw error; @@ -4420,33 +6569,33 @@ async function runCommand(argv, io) { } return; } - if (command === "inspect" && subcommand === "tempo" && identifier !== undefined) { + if (command === "inspect" && subcommand === "tempo" && identifier2 !== undefined) { rejectUnused(parsed, ["data-dir", "session-gap", "burst-gap"], ["json"]); const context = await existingStore(parsed); try { - const metrics = contactMetrics(context.store, identifier, metricOptions(parsed)); + const metrics = contactMetrics(context.store, identifier2, metricOptions(parsed)); const result = compactMetrics(metrics); - emit(io, json, result, `Tempo metrics for ${identifier}: ${metrics.tempo.responseEpisodes} response episodes`); + emit(io, json, result, `Tempo metrics for ${identifier2}: ${metrics.tempo.responseEpisodes} response episodes`); } finally { context.store.close(); } return; } - if (command === "inspect" && subcommand === "sessions" && identifier !== undefined) { + if (command === "inspect" && subcommand === "sessions" && identifier2 !== undefined) { rejectUnused(parsed, ["data-dir", "limit", "session-gap", "burst-gap"], ["json"]); const context = await existingStore(parsed); try { - const metrics = contactMetrics(context.store, identifier, metricOptions(parsed)); + const metrics = contactMetrics(context.store, identifier2, metricOptions(parsed)); const limit = integerOption(parsed, "limit", 20, 1, 1000); const sessions = metrics.sessions.slice(-limit); - const result = { contactId: identifier, total: metrics.sessions.length, sessions }; - emit(io, json, result, `${sessions.length} of ${metrics.sessions.length} sessions for ${identifier}`); + const result = { contactId: identifier2, total: metrics.sessions.length, sessions }; + emit(io, json, result, `${sessions.length} of ${metrics.sessions.length} sessions for ${identifier2}`); } finally { context.store.close(); } return; } - if (command === "study" && subcommand === "prepare" && identifier !== undefined) { + if (command === "study" && subcommand === "prepare" && identifier2 !== undefined) { rejectUnused(parsed, [ "data-dir", "output", @@ -4463,13 +6612,13 @@ async function runCommand(argv, io) { const before = canonicalTimestampOption(parsed, "before"); let evidence; try { - evidence = contactEvidence(context.store, identifier, { after, before }); + evidence = contactEvidence(context.store, identifier2, { after, before }); } catch (error) { if (error instanceof CliError) throw error; throw new CliError("usage", error instanceof Error ? error.message : String(error), { cause: error }); } - const metrics = analyzeContact(evidence.messages, evidence.corpusRevision, identifier, metricOptions(parsed)); + const metrics = analyzeContact(evidence.messages, evidence.corpusRevision, identifier2, { ...metricOptions(parsed), reactionFacts: evidence.reactions }); const packet = buildStudyPacket(evidence.messages, metrics, { limit: integerOption(parsed, "limit", 24, 1, 50), generatedAt: canonicalNow(io), @@ -4481,7 +6630,7 @@ async function runCommand(argv, io) { await atomicWritePrivate(output, bytes); context.store.recordStudyPacket({ sha256: packetSha256, - contactId: identifier, + contactId: identifier2, corpusRevision: metrics.corpusRevision, evidenceRevision: evidence.evidenceRevision, createdAt: packet.generatedAt, @@ -4500,7 +6649,7 @@ async function runCommand(argv, io) { } }); const result = { - contactId: identifier, + contactId: identifier2, corpusRevision: metrics.corpusRevision, evidenceRevision: evidence.evidenceRevision, packetSha256, @@ -4514,7 +6663,7 @@ async function runCommand(argv, io) { } return; } - if (command === "evaluate" && subcommand === "prepare" && identifier !== undefined) { + if (command === "evaluate" && subcommand === "prepare" && identifier2 !== undefined) { rejectUnused(parsed, [ "data-dir", "after", @@ -4536,13 +6685,13 @@ async function runCommand(argv, io) { try { let evidence; try { - evidence = contactEvidence(context.store, identifier, { after, before }); + evidence = contactEvidence(context.store, identifier2, { after, before }); } catch (error) { if (error instanceof CliError) throw error; throw new CliError("usage", error instanceof Error ? error.message : String(error), { cause: error }); } - const metrics = analyzeContact(evidence.messages, evidence.corpusRevision, identifier, metricOptions(parsed)); + const metrics = analyzeContact(evidence.messages, evidence.corpusRevision, identifier2, { ...metricOptions(parsed), reactionFacts: evidence.reactions }); const packets = buildEvaluationPackets(evidence.messages, metrics, { after, before, @@ -4557,7 +6706,7 @@ async function runCommand(argv, io) { await atomicWritePrivate(referenceOutput, prettyJson(packets.reference)); const result = { evaluationId: packets.prompt.evaluationId, - contactId: identifier, + contactId: identifier2, corpusRevision: evidence.corpusRevision, evidenceRevision: evidence.evidenceRevision, cases: packets.prompt.cases.length, @@ -4572,9 +6721,9 @@ async function runCommand(argv, io) { } return; } - if (command === "profile" && subcommand === "apply" && identifier !== undefined) { + if (command === "profile" && subcommand === "apply" && identifier2 !== undefined) { rejectUnused(parsed, ["data-dir"], ["json"]); - const path = absolutePrivatePath(identifier, "Profile path"); + const path = absolutePrivatePath(identifier2, "Profile path"); const profile = await readStyleProfile(path); const context = await existingStore(parsed); try { @@ -4586,38 +6735,38 @@ async function runCommand(argv, io) { } return; } - if (command === "profile" && subcommand === "show" && identifier !== undefined) { + if (command === "profile" && subcommand === "show" && identifier2 !== undefined) { rejectUnused(parsed, ["data-dir"], ["json"]); const context = await existingStore(parsed); try { - requireContact(context.store, identifier); - const result = context.store.profile(identifier); + requireContact(context.store, identifier2); + const result = context.store.profile(identifier2); if (result === null) - throw new CliError("not-found", `No profile exists for ${identifier}`); - emit(io, json, result, `${result.state} profile for ${identifier}`); + throw new CliError("not-found", `No profile exists for ${identifier2}`); + emit(io, json, result, `${result.state} profile for ${identifier2}`); } finally { context.store.close(); } return; } - if (command === "profile" && subcommand === "export" && identifier !== undefined) { + if (command === "profile" && subcommand === "export" && identifier2 !== undefined) { rejectUnused(parsed, ["data-dir", "output"], ["json"]); const output = absolutePrivatePath(parsed.options.get("output"), "--output"); const context = await existingStore(parsed); try { - requireContact(context.store, identifier); - const result = context.store.profile(identifier); + requireContact(context.store, identifier2); + const result = context.store.profile(identifier2); if (result === null) - throw new CliError("not-found", `No profile exists for ${identifier}`); + throw new CliError("not-found", `No profile exists for ${identifier2}`); await atomicWritePrivate(output, prettyJson(result.profile)); - const receipt = { contactId: identifier, state: result.state, output }; + const receipt = { contactId: identifier2, state: result.state, output }; emit(io, json, receipt, `Exported ${result.state} profile to ${output}`); } finally { context.store.close(); } return; } - if (command === "context" && subcommand !== undefined && identifier === undefined) { + if (command === "context" && subcommand !== undefined && identifier2 === undefined) { rejectUnused(parsed, ["data-dir"], ["json"]); const contactId = subcommand; const context = await existingStore(parsed); @@ -4633,13 +6782,13 @@ async function runCommand(argv, io) { } return; } - if (command === "skill" && subcommand === "path" && identifier === undefined) { + if (command === "skill" && subcommand === "path" && identifier2 === undefined) { rejectUnused(parsed, ["data-dir"], ["json"]); const path = bundledSkillPath(); emit(io, json, { path }, path); return; } - if (command === "skill" && subcommand === "install" && identifier === undefined) { + if (command === "skill" && subcommand === "install" && identifier2 === undefined) { rejectUnused(parsed, ["data-dir", "target", "scope", "project"], ["force", "json"]); const target = parsed.options.get("target") ?? "codex"; const scope = parsed.options.get("scope") ?? "user"; diff --git a/dist/index.js b/dist/index.js index 7bd5cc0..f674631 100644 --- a/dist/index.js +++ b/dist/index.js @@ -4,18 +4,20 @@ import { CORPUS_SCHEMA_VERSION, EVALUATION_PACKET_SCHEMA_VERSION, LEGACY_PROFILE_SCHEMA_VERSION, + MESSAGE_BUNDLE_SCHEMA_VERSION, METRICS_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, STUDY_PACKET_SCHEMA_VERSION, canonicalJson, sha256 -} from "./cli-xby0v0et.js"; +} from "./cli-mxxakdqk.js"; export { sha256, canonicalJson, STUDY_PACKET_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, METRICS_SCHEMA_VERSION, + MESSAGE_BUNDLE_SCHEMA_VERSION, LEGACY_PROFILE_SCHEMA_VERSION, EVALUATION_PACKET_SCHEMA_VERSION, CORPUS_SCHEMA_VERSION, diff --git a/dist/types.d.ts b/dist/types.d.ts index 7e5176e..5252ea7 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -1,10 +1,11 @@ export declare const CORPUS_SCHEMA_VERSION: 1; -export declare const METRICS_SCHEMA_VERSION: 1; +export declare const METRICS_SCHEMA_VERSION: 2; export declare const PROFILE_SCHEMA_VERSION: 2; export declare const LEGACY_PROFILE_SCHEMA_VERSION: 1; export declare const STUDY_PACKET_SCHEMA_VERSION: 2; export declare const EVALUATION_PACKET_SCHEMA_VERSION: 1; export declare const CONTACTS_SCHEMA_VERSION: 1; +export declare const MESSAGE_BUNDLE_SCHEMA_VERSION: 1; export type Direction = "incoming" | "outgoing"; export type BodySource = "text" | "attributed-body" | "unavailable"; export type MessageKind = "text" | "attachment" | "reaction" | "system" | "unknown"; @@ -50,6 +51,94 @@ export type CorpusSnapshot = Readonly<{ messages: readonly CorpusMessage[]; warnings: readonly string[]; }>; +export type CorpusSourceKind = "imessage" | "bundle"; +export type CorpusSourceCoverage = Readonly<{ + history: "complete-current-local" | "bounded" | "unknown"; + observedFrom: string | null; + observedTo: string | null; + /** Producer-specific completeness classification, when the import format has one. */ + kind?: string; + /** Producer-supplied categorical reason for incomplete coverage. */ + reason?: string | null; +}>; +export type CorpusSourceDescriptor = Readonly<{ + /** Per-install source pseudonym used by the local store and CLI. */ + id: string; + kind: CorpusSourceKind; + provider: string; + network: string | null; + /** Private provider account identifier. Ordinary source views omit it. */ + accountId: string | null; + /** Private producer-local source identifier. Ordinary source views omit it. */ + externalId: string; + revision: string; + generatedAt: string | null; + producer: Readonly<{ + id: string; + version: string; + }>; + coverage: CorpusSourceCoverage; + manifestSha256: string | null; + identity: unknown; + warnings: readonly string[]; +}>; +export type CorpusConversationProvenance = Readonly<{ + conversationId: string; + externalId: string; + metadata?: unknown; +}>; +export type CorpusAttachmentProvenance = Readonly<{ + id: string; + kind: string | null; + mimeType: string | null; + fileName: string | null; + bytes: number | null; +}>; +export type CorpusMessageProvenance = Readonly<{ + messageId: string; + externalId: string; + replyToExternalId: string | null; + attachments: readonly CorpusAttachmentProvenance[]; + metadata?: unknown; +}>; +export type CorpusReactionFact = Readonly<{ + id: string; + externalId: string; + targetExternalId: string; + conversationId: string | null; + direction: Direction | null; + body: string; + reactedAt: string | null; + state: "active" | "removed"; +}>; +export type CorpusSourceRecord = Readonly<{ + kind: "account" | "participant" | "reaction" | "tombstone" | "excluded-message"; + id: string; + record: unknown; +}>; +export type CorpusSourceDeletion = Readonly<{ + entityKind: "account" | "participant" | "conversation" | "message" | "reaction" | "reaction-timeline"; + localEntityId: string | null; + externalId: string; + deletedAt: string; + expectedConversationId?: string; + reason?: "tombstone" | "explicit-exclusion" | "replacement"; +}>; +export type SourceCorpusSnapshot = Readonly<{ + source: CorpusSourceDescriptor; + conversations: readonly CorpusConversation[]; + conversationProvenance: readonly CorpusConversationProvenance[]; + messages: readonly CorpusMessage[]; + messageProvenance: readonly CorpusMessageProvenance[]; + reactionFacts?: readonly CorpusReactionFact[]; + auxiliaryRecords?: readonly CorpusSourceRecord[]; + deletions?: readonly CorpusSourceDeletion[]; +}>; +export type MessageBundleSnapshot = Readonly<{ + schemaVersion: typeof MESSAGE_BUNDLE_SCHEMA_VERSION; + manifestSha256: string; + sources: readonly SourceCorpusSnapshot[]; +}>; export type ContactHandle = Readonly<{ kind: "email" | "phone"; normalizedValue: string; @@ -161,7 +250,17 @@ export type ReactionMetrics = Readonly<{ total: number; incoming: number; outgoing: number; + unknownDirection: number; + dated: number; + undated: number; outgoingReactionRatio: number; + byBody: readonly Readonly<{ + body: string; + total: number; + incoming: number; + outgoing: number; + unknownDirection: number; + }>[]; }>; export type ContactMetrics = Readonly<{ schemaVersion: typeof METRICS_SCHEMA_VERSION; diff --git a/docs/local-message-bundle-v1.md b/docs/local-message-bundle-v1.md new file mode 100644 index 0000000..9218898 --- /dev/null +++ b/docs/local-message-bundle-v1.md @@ -0,0 +1,207 @@ +# Local message bundle version 1 + +`message-like-me.local-message-bundle` is a private directory interchange for +moving a bounded local provider observation into Message Like Me. It separates +provider capture from analysis: a producer handles provider access and writes +the bundle, while `messagelikeme ingest bundle` verifies and normalizes it. The +importer never receives provider credentials and never calls the producer. + +The current producer is Wrench's local Beeper export: + +```sh +wrench beeper export-message-like-me \ + --auth \ + --output \ + [--limit-chats ] \ + [--limit-messages ] \ + [--max-participants ] \ + [--json] +``` + +The JSON shape is published as +[`schema/local-message-bundle-v1.schema.json`](../schema/local-message-bundle-v1.schema.json). +Runtime validation also enforces UTF-8 byte bounds, canonical encoding, +filesystem identity, graph joins, and digest laws that JSON Schema cannot +express. + +## Directory inventory + +The input is a normalized absolute path to a current-user-owned physical +directory with mode `0700`. It contains exactly these mode-`0600`, singly +linked physical files: + +```text +manifest.json +accounts.ndjson +participants.ndjson +conversations.ndjson +messages.ndjson +reactions.ndjson +tombstones.ndjson +``` + +The six artifacts always exist, including when they are empty. Every artifact +uses canonical JSON, one object per line, and one final newline per record. +Empty artifacts contain zero bytes. `manifest.json` is canonical JSON followed +by one newline and is written last by the producer. + +The importer rejects symbolic links, extra files, ownership or mode changes, +files that change while read, invalid UTF-8, noncanonical JSON, blank records, +missing final newlines, count or byte mismatches, and digest mismatches. + +## Bounds + +Version one has these hard importer and producer ceilings: + +- 128 connected accounts; +- 500,000 records across all six artifacts; +- 512 MiB across all six artifacts; +- 2 MiB for one encoded NDJSON record, including its final newline; +- 1 MiB of UTF-8 for one message body; +- 1,024 UTF-8 bytes for an identifier, sort key, or provider revision; +- 8 KiB of UTF-8 for a display name, handle, title, reaction body, or + attachment filename; +- 10,000 known participants in one conversation; +- 256 attachment metadata items in one message; and +- 128 unique categorical warning codes. + +Custom producer limits may only lower the total record, byte, and line bounds. +All timestamps are canonical millisecond UTC strings equal to +`Date#toISOString()` output. Identifiers are nonempty and contain no ASCII +control characters. Network and warning values are bounded lowercase tokens. + +## Manifest integrity + +The manifest declares source and provider versions, collection timestamps, +completeness, privacy guarantees, per-kind counts, and each artifact's exact +record count, byte length, and lowercase SHA-256. + +Artifact SHA-256 covers the file's exact bytes, including every final newline. +Artifacts appear in the fixed directory order shown above. The bundle digest +is: + +```text +SHA256(UTF8(canonicalJson(manifest with the entire integrity property omitted))) +``` + +The manifest file's own SHA-256 covers its exact canonical bytes plus final +newline. It is returned by the producer and recorded by Message Like Me, but is +not embedded in the manifest. + +The privacy declaration is fixed to `private-local`, `metadata-only` +attachments, excluded provider URLs, and excluded credentials. This is an +interchange constraint, not anonymization. Bodies, timestamps, handles, +account coordinates, and relationship graphs remain private. + +## Account realms and provenance + +Every line has `schemaVersion`, `kind`, a bundle-local `id`, `accountId`, +`network`, and provenance: + +- `providerId` is the stable provider coordinate for that entity; +- `providerRevision` preserves a provider revision when one exists; +- `observedAt` records when the producer observed this record; and +- `connectedAccountProviderId` is the stable connected-account coordinate. + +An account line has `id === accountId` and +`provenance.providerId === provenance.connectedAccountProviderId`. Every other +record must match one account line on `accountId`, `network`, and connected +account coordinate. Bundle-local IDs exist only for joins inside this one +bundle. Message Like Me derives its stored source and entity IDs from stable +provider, connected-account, and self-participant coordinates with a private +per-install HMAC key. The mutable network label is source metadata and never +part of that identity namespace. + +Provider IDs must be unique within one entity kind and account. Validation +errors name the record kind and ordinal, never the foreign coordinate. Message +and reaction provider IDs are independent domains and may contain the same +value. Message Like Me assigns a separate internal timeline coordinate when a +dated reaction is represented in the normalized messages table; the raw +reaction coordinate remains in the reaction fact and private provenance. + +## Identity and conversation rosters + +Each account names one self participant. Participants carry optional display +names and handles plus `isSelf`. Conversations carry their known participant +IDs and `participantsComplete`: + +- `true` is the only positive assertion that the roster is complete; +- `false` or `null` means the producer cannot assert completeness; and +- a complete direct roster must contain exactly the account's one self + participant and one non-self participant. + +Message senders and reaction actors must agree with direction and any complete +roster. Message Like Me may expose an exact email or E.164 handle from the one +non-self participant of a complete direct conversation to local Contacts +matching. It never uses an incomplete roster for that join. + +## Messages, replies, and attachments + +`sentAt` is the message's actual temporal coordinate. `sortKey` is an opaque +provider ordering key. Within one account and conversation, Message Like Me +orders lexical `sortKey`, then `sentAt` and ID as deterministic tie-breakers. + +`bodyTruncated: true` means the body cannot be prose evidence. The record still +becomes a text bubble for tempo, reply, and delivery-shape analysis. A message +with deletion state must have a null body. Attachment entries contain metadata +only; they never contain paths, URLs, or media bytes. + +A reply target has a required provider ID and an optional bundle-local ID. When +the local ID is present, it must resolve to the same provider coordinate in the +same account and conversation. A null local ID preserves a reply to a message +outside the bounded artifact. + +## Edits and deletion + +Edits are discriminated: + +- `in-place` records a terminal mutation under the same provider message ID and + never suppresses that message; and +- `replacement` identifies a different provider message in the same account + and conversation. + +Replacement targets may be outside the bounded artifact. In-bundle targets +must agree on local and provider coordinates. Replacement graphs must be +non-self, single-terminal, and acyclic. A validated replacement suppresses the +older version as evidence. + +Message deletion state is explicit and carries the observation time and +provider revision. Deleted bodies are null. + +## Reactions and tombstones + +A reaction has a required target provider message ID and an optional +bundle-local target. When present, the local target must resolve to the same +provider coordinate. `reactedAt` is nullable because the provider may not +expose a reaction time. Producers never synthesize one. Active undated +reactions contribute to body and direction counts but never enter the message +timeline, sessions, bursts, response episodes, or latency metrics. + +Tombstones identify a conversation, message, or reaction kind, required +provider coordinate, optional bundle-local coordinate, deletion time, scope, +and provider revision. Account and participant tombstones are outside the +version-one contract. A nonnull local coordinate must resolve inside the same +account and agree with +the provider coordinate. A null coordinate preserves deletion knowledge for +an entity outside the bounded artifact. + +## Reimport semantics + +Version-one bundle completeness is `bounded-local`, `truncated`, or `unknown`. +None is authoritative for deletion by absence. Reimport therefore upserts +present records and retains prior records omitted by a later bundle. Explicit +message deletion, removed reaction state, replacement edges, and tombstones +are applied separately. A valid later reappearance clears the matching +suppression. + +The manifest completeness kind and reason apply conservatively to every +account. Stored `observedFrom` and `observedTo` bounds are derived from the +dated message and reaction records for that account, so one account never +inherits another account's time range. An account with no dated timeline +records has null bounds. + +`timestamps.createdAt` is monotonic within one stable connected-account source. +An older bundle is rejected. An equal-time replay is accepted only when its +manifest and input revision match exactly; an equal-time conflict is rejected. +Native iMessage replacement remains scoped to its own source and cannot remove +bundle history. diff --git a/docs/methodology.md b/docs/methodology.md index b23874b..33b6c32 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -13,6 +13,10 @@ The CLI makes stable private snapshots and opens only those snapshots through SQLite. It does not modify Messages, Contacts, their databases, or their transactional sidecars. +A caller-owned local message bundle is a separate versioned source +observation. The CLI verifies its complete fixed inventory and digests before +ingest, never obtains its provider credential, and does not call its producer. + The normalized corpus, private installation key, aggregate metrics, profiles, and drafting context stay in the local data root. Study and evaluation files are written only to explicit paths. Ordinary views use keyed pseudonymous IDs @@ -26,11 +30,21 @@ of the user's prose. ## Normalized observations -The corpus preserves message direction, timestamp, body availability and -source, message kind, attachment count, edit or retraction metadata, explicit -reply target, service, and conversation membership where the source supports -them. Unsupported or missing text remains unavailable rather than being -reconstructed. +The corpus preserves source, account, network, message direction, provider +ordering, timestamp, body availability and source, message kind, attachment +metadata, edit or retraction metadata, explicit reply target, service, and +conversation membership where the source supports them. Unsupported, deleted, +or truncated text remains unavailable rather than being reconstructed. A +truncated text record still represents a message bubble for tempo and reply +evidence, but never contributes prose. + +Native iMessage history and each connected bundle account have distinct source +namespaces. A bounded, truncated, or unknown bundle is not an authoritative +statement that omitted history no longer exists. Reimport merges present +records with retained state. Only explicit deletion, removal, replacement, or +tombstone state suppresses evidence, and a later record reappearance clears +that suppression. Bundle creation times are monotonic per source, so an older +snapshot cannot resurrect or overwrite newer state. The analysis uses several operational units: @@ -44,7 +58,9 @@ The analysis uses several operational units: in the same session. - An **explicit reply** is source metadata linking a message to an earlier message. It is distinct from a reaction or an ordinary adjacent response. -- A **reaction** is counted as interaction behavior, not authored prose. +- A **reaction** is counted as interaction behavior, not authored prose. A + reaction without a provider timestamp contributes to counts and direction + but not to temporal order, sessions, bursts, or response episodes. Five minutes and eight hours are reproducible segmentation parameters, not claims about natural conversational boundaries. Every metrics artifact records @@ -57,7 +73,11 @@ For each conversation, the CLI reports the evidence window and counts of incoming, outgoing, text, session, burst, and response records. Tempo metrics include response-latency quantiles, outgoing messages per response, the ratio of single-message to multi-message responses, multi-message inbound contexts, -visible multi-question contexts, and explicit reply frequency. +visible multi-question contexts, and explicit reply frequency. Session, burst, +and response construction runs independently for each source conversation +before person-scope results are combined. Adjacent timestamps in two apps or +threads never create one artificial episode. Mixed person scopes expose their +sorted service breakdown. Surface measurements cover characters and words, lowercase starts, terminal punctuation, question and exclamation marks, emoji-bearing messages, and @@ -83,7 +103,7 @@ each direction keeps at most 12 text messages per example, and total emitted body text is capped at 256 KiB. Coverage metadata states what was truncated or omitted. A packet is a sample of response contexts, not a transcript. -Version 0.2 adds temporal bounds to study selection. A profile intended for +Version 0.2 added temporal bounds to study selection. A profile intended for held-out evaluation should use only examples before the chosen cutoff. The cutoff, corpus revision, selection parameters, packet receipt, and evidence window form part of the analysis provenance. Profile validity uses a digest of @@ -171,6 +191,8 @@ sending, reacting, scheduling, or operating a messaging application. Reported behavior can be distorted by: - Messages that are not synchronized to the Mac or are no longer present; +- partial local provider exports whose completeness bounds exclude older or + remote history; - unsupported body encodings, attachments, edits, retractions, or source schema changes; - ambiguous or stale Contacts labels; diff --git a/docs/research.md b/docs/research.md index 189528a..6927b75 100644 --- a/docs/research.md +++ b/docs/research.md @@ -8,7 +8,7 @@ that boundary and the neighboring open-source work that informed it. The cited papers are primary research publications or preprints. Project descriptions link to their official repositories. A paper result is evidence about the task and population it evaluated, not proof that the same result -holds for private iMessage conversations. +holds for private conversations across the messaging sources a user imports. ## Personalization is contextual @@ -110,8 +110,9 @@ conversation partner or make a hosted agent local. A 2026 preprint on [response times in donated WhatsApp and Instagram chats](https://arxiv.org/abs/2605.03687) reported persistent response-speed similarity between chat partners in its sample. This is preliminary evidence from different platforms and cannot set a -norm for iMessage users. It does support comparing tempo within a dyad instead -of treating one global latency distribution as a personal rule. +norm for users of any supported messaging source. It does support comparing +tempo within a dyad instead of treating one global latency distribution as a +personal rule. Historical latency is affected by sleep, work, travel, notifications, device availability, urgency, and missing data. Message Like Me reports it as diff --git a/package.json b/package.json index 9a97e06..fa1d551 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hraness/message-like-me", - "version": "0.2.0", - "description": "A local-first CLI and Agent Skill for studying your private iMessage history and drafting messages that sound like you.", + "version": "0.3.0", + "description": "A local-first CLI and Agent Skill for studying private messaging history and drafting messages that sound like you.", "license": "MIT", "type": "module", "packageManager": "bun@1.3.14", @@ -18,10 +18,12 @@ }, "keywords": [ "agent-skill", + "beeper", "bun", "cli", "imessage", "local-first", + "messaging", "messaging-style", "privacy" ], diff --git a/schema/local-message-bundle-v1.schema.json b/schema/local-message-bundle-v1.schema.json new file mode 100644 index 0000000..34ee2ea --- /dev/null +++ b/schema/local-message-bundle-v1.schema.json @@ -0,0 +1,434 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://messagelikeme.com/schema/local-message-bundle-v1.schema.json", + "title": "Message Like Me local message bundle v1 JSON objects", + "description": "Validates manifest.json or one NDJSON record. Runtime validation additionally enforces canonical UTF-8 bytes, filesystem safety, byte bounds, digests, account realms, identity joins, and graph laws.", + "oneOf": [ + { "$ref": "#/$defs/manifest" }, + { "$ref": "#/$defs/account" }, + { "$ref": "#/$defs/participant" }, + { "$ref": "#/$defs/conversation" }, + { "$ref": "#/$defs/message" }, + { "$ref": "#/$defs/reaction" }, + { "$ref": "#/$defs/tombstone" } + ], + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^\\u0000-\\u001F\\u007F]+$", + "description": "Runtime bound is 1,024 UTF-8 bytes." + }, + "shortText": { + "type": "string", + "maxLength": 8192, + "pattern": "^[^\\u0000]*$", + "description": "Runtime bound is 8 KiB UTF-8." + }, + "body": { + "type": "string", + "maxLength": 1048576, + "pattern": "^[^\\u0000]*$", + "description": "Runtime bound is 1 MiB UTF-8." + }, + "token": { + "type": "string", + "maxLength": 128, + "pattern": "^[a-z0-9](?:[a-z0-9._+\\-]*[a-z0-9])?$" + }, + "network": { + "type": "string", + "maxLength": 64, + "pattern": "^[a-z0-9](?:[a-z0-9._+\\-]*[a-z0-9])?$" + }, + "version": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9._+\\-]*[A-Za-z0-9])?$" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "maxLength": 64, + "description": "Runtime requires exact canonical Date#toISOString() millisecond UTC text." + }, + "digest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "nullableIdentifier": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/identifier" } + ] + }, + "nullableShortText": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/shortText" } + ] + }, + "nullableTimestamp": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/timestamp" } + ] + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": [ + "providerId", + "providerRevision", + "observedAt", + "connectedAccountProviderId" + ], + "properties": { + "providerId": { "$ref": "#/$defs/identifier" }, + "providerRevision": { "$ref": "#/$defs/nullableIdentifier" }, + "observedAt": { "$ref": "#/$defs/timestamp" }, + "connectedAccountProviderId": { "$ref": "#/$defs/identifier" } + } + }, + "common": { + "type": "object", + "required": ["schemaVersion", "kind", "id", "accountId", "network", "provenance"], + "properties": { + "schemaVersion": { "const": 1 }, + "kind": { + "enum": ["account", "participant", "conversation", "message", "reaction", "tombstone"] + }, + "id": { "$ref": "#/$defs/identifier" }, + "accountId": { "$ref": "#/$defs/identifier" }, + "network": { "$ref": "#/$defs/network" }, + "provenance": { "$ref": "#/$defs/provenance" } + } + }, + "account": { + "allOf": [{ "$ref": "#/$defs/common" }], + "type": "object", + "unevaluatedProperties": false, + "required": ["displayName", "handle", "selfParticipantId"], + "properties": { + "kind": { "const": "account" }, + "displayName": { "$ref": "#/$defs/nullableShortText" }, + "handle": { "$ref": "#/$defs/nullableShortText" }, + "selfParticipantId": { "$ref": "#/$defs/identifier" } + } + }, + "participant": { + "allOf": [{ "$ref": "#/$defs/common" }], + "type": "object", + "unevaluatedProperties": false, + "required": ["displayName", "handle", "isSelf"], + "properties": { + "kind": { "const": "participant" }, + "displayName": { "$ref": "#/$defs/nullableShortText" }, + "handle": { "$ref": "#/$defs/nullableShortText" }, + "isSelf": { "type": "boolean" } + } + }, + "conversation": { + "allOf": [{ "$ref": "#/$defs/common" }], + "type": "object", + "unevaluatedProperties": false, + "required": [ + "type", + "title", + "participantIds", + "participantsComplete", + "startedAt", + "lastMessageAt" + ], + "properties": { + "kind": { "const": "conversation" }, + "type": { "enum": ["direct", "group", "channel", "unknown"] }, + "title": { "$ref": "#/$defs/nullableShortText" }, + "participantIds": { + "type": "array", + "maxItems": 10000, + "uniqueItems": true, + "items": { "$ref": "#/$defs/identifier" } + }, + "participantsComplete": { "type": ["boolean", "null"] }, + "startedAt": { "$ref": "#/$defs/nullableTimestamp" }, + "lastMessageAt": { "$ref": "#/$defs/nullableTimestamp" } + } + }, + "attachment": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "mimeType", "name", "sizeBytes"], + "properties": { + "kind": { "enum": ["audio", "document", "image", "link", "sticker", "video", "unknown"] }, + "mimeType": { "$ref": "#/$defs/nullableShortText" }, + "name": { "$ref": "#/$defs/nullableShortText" }, + "sizeBytes": { + "oneOf": [ + { "type": "null" }, + { "type": "integer", "minimum": 0, "maximum": 9007199254740991 } + ] + } + } + }, + "reply": { + "type": "object", + "additionalProperties": false, + "required": ["messageId", "providerId"], + "properties": { + "messageId": { "$ref": "#/$defs/nullableIdentifier" }, + "providerId": { "$ref": "#/$defs/identifier" } + } + }, + "editInPlace": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "editedAt", "providerRevision"], + "properties": { + "kind": { "const": "in-place" }, + "editedAt": { "$ref": "#/$defs/timestamp" }, + "providerRevision": { "$ref": "#/$defs/identifier" } + } + }, + "editReplacement": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "replacesMessageId", "replacesProviderId", "editedAt", "providerRevision"], + "properties": { + "kind": { "const": "replacement" }, + "replacesMessageId": { "$ref": "#/$defs/nullableIdentifier" }, + "replacesProviderId": { "$ref": "#/$defs/identifier" }, + "editedAt": { "$ref": "#/$defs/timestamp" }, + "providerRevision": { "$ref": "#/$defs/identifier" } + } + }, + "deletion": { + "type": "object", + "additionalProperties": false, + "required": ["state", "observedAt", "providerRevision"], + "properties": { + "state": { "enum": ["revoked", "deleted-for-me", "revoked-and-deleted-for-me"] }, + "observedAt": { "$ref": "#/$defs/timestamp" }, + "providerRevision": { "$ref": "#/$defs/nullableIdentifier" } + } + }, + "message": { + "allOf": [{ "$ref": "#/$defs/common" }], + "type": "object", + "unevaluatedProperties": false, + "required": [ + "conversationId", + "senderParticipantId", + "direction", + "sentAt", + "sortKey", + "body", + "bodyTruncated", + "replyTo", + "edit", + "deletion", + "attachments" + ], + "properties": { + "kind": { "const": "message" }, + "conversationId": { "$ref": "#/$defs/identifier" }, + "senderParticipantId": { "$ref": "#/$defs/nullableIdentifier" }, + "direction": { "enum": ["incoming", "outgoing", "unknown"] }, + "sentAt": { "$ref": "#/$defs/timestamp" }, + "sortKey": { "$ref": "#/$defs/identifier" }, + "body": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/body" } + ] + }, + "bodyTruncated": { "type": ["boolean", "null"] }, + "replyTo": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/reply" } + ] + }, + "edit": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/editInPlace" }, + { "$ref": "#/$defs/editReplacement" } + ] + }, + "deletion": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/deletion" } + ] + }, + "attachments": { + "type": "array", + "maxItems": 256, + "items": { "$ref": "#/$defs/attachment" } + } + } + }, + "reaction": { + "allOf": [{ "$ref": "#/$defs/common" }], + "type": "object", + "unevaluatedProperties": false, + "required": ["messageId", "messageProviderId", "participantId", "body", "reactedAt", "state"], + "properties": { + "kind": { "const": "reaction" }, + "messageId": { "$ref": "#/$defs/nullableIdentifier" }, + "messageProviderId": { "$ref": "#/$defs/identifier" }, + "participantId": { "$ref": "#/$defs/nullableIdentifier" }, + "body": { "$ref": "#/$defs/shortText" }, + "reactedAt": { "$ref": "#/$defs/nullableTimestamp" }, + "state": { "enum": ["active", "removed"] } + } + }, + "tombstone": { + "allOf": [{ "$ref": "#/$defs/common" }], + "type": "object", + "unevaluatedProperties": false, + "required": ["entityKind", "entityId", "entityProviderId", "deletedAt", "scope", "providerRevision"], + "properties": { + "kind": { "const": "tombstone" }, + "entityKind": { "enum": ["conversation", "message", "reaction"] }, + "entityId": { "$ref": "#/$defs/nullableIdentifier" }, + "entityProviderId": { "$ref": "#/$defs/identifier" }, + "deletedAt": { "$ref": "#/$defs/timestamp" }, + "scope": { "enum": ["remote", "local", "unknown"] }, + "providerRevision": { "$ref": "#/$defs/nullableIdentifier" } + } + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["path", "mediaType", "recordKind", "records", "bytes", "sha256"], + "properties": { + "path": { "enum": ["accounts.ndjson", "participants.ndjson", "conversations.ndjson", "messages.ndjson", "reactions.ndjson", "tombstones.ndjson"] }, + "mediaType": { "const": "application/x-ndjson" }, + "recordKind": { "enum": ["account", "participant", "conversation", "message", "reaction", "tombstone"] }, + "records": { "type": "integer", "minimum": 0, "maximum": 500000 }, + "bytes": { "type": "integer", "minimum": 0, "maximum": 536870912 }, + "sha256": { "$ref": "#/$defs/digest" } + } + }, + "manifest": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "format", + "source", + "provider", + "timestamps", + "completeness", + "warnings", + "privacy", + "counts", + "artifacts", + "integrity" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "format": { "const": "message-like-me.local-message-bundle" }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version"], + "properties": { + "id": { "const": "beeper-local" }, + "version": { "$ref": "#/$defs/version" } + } + }, + "provider": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version"], + "properties": { + "id": { "const": "beeper" }, + "version": { "$ref": "#/$defs/version" } + } + }, + "timestamps": { + "type": "object", + "additionalProperties": false, + "required": ["startedAt", "finishedAt", "createdAt"], + "properties": { + "startedAt": { "$ref": "#/$defs/timestamp" }, + "finishedAt": { "$ref": "#/$defs/timestamp" }, + "createdAt": { "$ref": "#/$defs/timestamp" } + } + }, + "completeness": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "reason", "observedFrom", "observedThrough"], + "properties": { + "kind": { "enum": ["bounded-local", "truncated", "unknown"] }, + "reason": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/token" } + ] + }, + "observedFrom": { "$ref": "#/$defs/nullableTimestamp" }, + "observedThrough": { "$ref": "#/$defs/nullableTimestamp" } + } + }, + "warnings": { + "type": "array", + "maxItems": 128, + "uniqueItems": true, + "items": { "$ref": "#/$defs/token" } + }, + "privacy": { + "type": "object", + "additionalProperties": false, + "required": ["classification", "attachments", "providerUrls", "credentials"], + "properties": { + "classification": { "const": "private-local" }, + "attachments": { "const": "metadata-only" }, + "providerUrls": { "const": "excluded" }, + "credentials": { "const": "excluded" } + } + }, + "counts": { + "type": "object", + "additionalProperties": false, + "required": ["account", "participant", "conversation", "message", "reaction", "tombstone"], + "properties": { + "account": { "type": "integer", "minimum": 0, "maximum": 128 }, + "participant": { "type": "integer", "minimum": 0, "maximum": 500000 }, + "conversation": { "type": "integer", "minimum": 0, "maximum": 500000 }, + "message": { "type": "integer", "minimum": 0, "maximum": 500000 }, + "reaction": { "type": "integer", "minimum": 0, "maximum": 500000 }, + "tombstone": { "type": "integer", "minimum": 0, "maximum": 500000 } + } + }, + "artifacts": { + "type": "array", + "minItems": 6, + "maxItems": 6, + "prefixItems": [ + { "allOf": [{ "$ref": "#/$defs/artifact" }, { "properties": { "path": { "const": "accounts.ndjson" }, "recordKind": { "const": "account" } } }] }, + { "allOf": [{ "$ref": "#/$defs/artifact" }, { "properties": { "path": { "const": "participants.ndjson" }, "recordKind": { "const": "participant" } } }] }, + { "allOf": [{ "$ref": "#/$defs/artifact" }, { "properties": { "path": { "const": "conversations.ndjson" }, "recordKind": { "const": "conversation" } } }] }, + { "allOf": [{ "$ref": "#/$defs/artifact" }, { "properties": { "path": { "const": "messages.ndjson" }, "recordKind": { "const": "message" } } }] }, + { "allOf": [{ "$ref": "#/$defs/artifact" }, { "properties": { "path": { "const": "reactions.ndjson" }, "recordKind": { "const": "reaction" } } }] }, + { "allOf": [{ "$ref": "#/$defs/artifact" }, { "properties": { "path": { "const": "tombstones.ndjson" }, "recordKind": { "const": "tombstone" } } }] } + ] + }, + "integrity": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "bundleSha256"], + "properties": { + "algorithm": { "const": "sha256" }, + "bundleSha256": { "$ref": "#/$defs/digest" } + } + } + } + } + } +} diff --git a/scripts/check-standalone.ts b/scripts/check-standalone.ts index 4d5bc4b..b3110c6 100644 --- a/scripts/check-standalone.ts +++ b/scripts/check-standalone.ts @@ -15,7 +15,7 @@ import { } from "node:path"; const PACKAGE_ROOT = resolve(fileURLToPath(new URL("../", import.meta.url))); -const PUBLIC_DESCRIPTION = "A local-first CLI and Agent Skill for studying your private iMessage history and drafting messages that sound like you."; +const PUBLIC_DESCRIPTION = "A local-first CLI and Agent Skill for studying private messaging history and drafting messages that sound like you."; const SCANNED_DIRECTORIES = [ ".github", "dist", diff --git a/scripts/local-message-bundle-schema.test.ts b/scripts/local-message-bundle-schema.test.ts new file mode 100644 index 0000000..3c39256 --- /dev/null +++ b/scripts/local-message-bundle-schema.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +type JsonObject = Record; + +function object(value: unknown, label: string): JsonObject { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as JsonObject; +} + +function array(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + return value; +} + +test("local message bundle schema publishes the frozen v1 contract", async () => { + const schema = object(JSON.parse(await readFile(join( + import.meta.dir, + "..", + "schema", + "local-message-bundle-v1.schema.json", + ), "utf8")) as unknown, "schema"); + expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema"); + expect(schema.$id).toBe("https://messagelikeme.com/schema/local-message-bundle-v1.schema.json"); + expect(array(schema.oneOf, "schema.oneOf")).toHaveLength(7); + + const definitions = object(schema.$defs, "schema.$defs"); + const manifest = object(definitions.manifest, "manifest"); + const manifestProperties = object(manifest.properties, "manifest.properties"); + expect(object(manifestProperties.format, "manifest.format").const) + .toBe("message-like-me.local-message-bundle"); + const counts = object(object(manifestProperties.counts, "manifest.counts").properties, "counts.properties"); + expect(object(counts.account, "counts.account").maximum).toBe(128); + const artifacts = object(manifestProperties.artifacts, "manifest.artifacts"); + expect(artifacts.minItems).toBe(6); + expect(artifacts.maxItems).toBe(6); + expect(array(artifacts.prefixItems, "manifest.artifacts.prefixItems")).toHaveLength(6); + + const message = object(definitions.message, "message"); + const messageProperties = object(message.properties, "message.properties"); + expect(object(messageProperties.sortKey, "message.sortKey").$ref).toBe("#/$defs/identifier"); + expect(array(object(messageProperties.edit, "message.edit").oneOf, "message.edit.oneOf")) + .toHaveLength(3); + + const reaction = object(definitions.reaction, "reaction"); + const reactionProperties = object(reaction.properties, "reaction.properties"); + expect(object(reactionProperties.messageProviderId, "reaction.messageProviderId").$ref) + .toBe("#/$defs/identifier"); + expect(object(reactionProperties.reactedAt, "reaction.reactedAt").$ref) + .toBe("#/$defs/nullableTimestamp"); + + const tombstone = object(definitions.tombstone, "tombstone"); + const tombstoneProperties = object(tombstone.properties, "tombstone.properties"); + expect(object(tombstoneProperties.entityKind, "tombstone.entityKind").enum) + .toEqual(["conversation", "message", "reaction"]); +}); diff --git a/scripts/package-smoke.ts b/scripts/package-smoke.ts index a198bb0..b162155 100644 --- a/scripts/package-smoke.ts +++ b/scripts/package-smoke.ts @@ -269,6 +269,21 @@ export async function packageSmoke(): Promise { if (schema.$schema !== "https://json-schema.org/draft/2020-12/schema") { throw new Error("Packed style profile schema must use JSON Schema draft 2020-12"); } + const bundleSchema = record( + JSON.parse( + await readFile( + join(installedPackage, "schema", "local-message-bundle-v1.schema.json"), + "utf8", + ), + ) as unknown, + "installed local message bundle schema", + ); + if ( + bundleSchema.$schema !== "https://json-schema.org/draft/2020-12/schema" + || bundleSchema.$id !== "https://messagelikeme.com/schema/local-message-bundle-v1.schema.json" + ) { + throw new Error("Packed local message bundle schema has the wrong identity"); + } } finally { await rm(work, { force: true, recursive: true }); } diff --git a/site/AGENTS.md b/site/AGENTS.md index 728d6f8..d6197bf 100644 --- a/site/AGENTS.md +++ b/site/AGENTS.md @@ -9,10 +9,12 @@ - Keep the page informational. It must never accept, upload, transmit, or request message history, contact data, study packets, profiles, or drafts. -- Keep the canonical product description exact and route installation to the - immutable GitHub release. -- Describe the CLI as local-first, bring-your-own-agent, and drafts-only. Never - imply that the site analyzes data or that Message Like Me sends messages. +- Keep the canonical product description exact: “A local-first CLI and Agent + Skill for studying private messaging history and drafting messages that sound + like you.” Route installation to the immutable GitHub release. +- Describe the CLI as local-first, bring-your-own-agent, source-aware, and + drafts-only. Never imply that the site analyzes data or that Message Like Me + sends messages. - Use synthetic examples only. Do not publish real counts, labels, handles, excerpts, identities, private paths, or derived personal profiles. - Use Bun 1.3.14 for installation and scripts. Run Vinext and ESLint through diff --git a/site/app/layout.tsx b/site/app/layout.tsx index da5f136..c36ce2d 100644 --- a/site/app/layout.tsx +++ b/site/app/layout.tsx @@ -3,9 +3,9 @@ import './globals.css'; export const metadata: Metadata = { metadataBase: new URL('https://messagelikeme.com'), - title: 'Message Like Me — Study how you text', + title: 'Message Like Me — Study how you message', description: - 'A local-first CLI and Agent Skill for studying your private iMessage history and drafting messages that sound like you.', + 'A local-first CLI and Agent Skill for studying private messaging history and drafting messages that sound like you.', alternates: { canonical: '/', }, @@ -13,23 +13,23 @@ export const metadata: Metadata = { type: 'website', url: '/', siteName: 'Message Like Me', - title: 'Message Like Me — Study how you text', + title: 'Message Like Me — Study how you message', description: - 'A local-first CLI and Agent Skill for studying your private iMessage history and drafting messages that sound like you.', + 'A local-first CLI and Agent Skill for studying private messaging history and drafting messages that sound like you.', images: [ { url: '/og.png', width: 1200, height: 630, - alt: 'Message Like Me — Study how you text.', + alt: 'Message Like Me — Study how you message.', }, ], }, twitter: { card: 'summary_large_image', - title: 'Message Like Me — Study how you text', + title: 'Message Like Me — Study how you message', description: - 'A local-first CLI and Agent Skill for studying your private iMessage history and drafting messages that sound like you.', + 'A local-first CLI and Agent Skill for studying private messaging history and drafting messages that sound like you.', images: ['/og.png'], }, }; diff --git a/site/app/page.tsx b/site/app/page.tsx index a660433..ee0d3ef 100644 --- a/site/app/page.tsx +++ b/site/app/page.tsx @@ -1,7 +1,7 @@ import { readmeHtml } from './readme.generated'; const githubUrl = 'https://github.com/hraness/message-like-me'; -const releaseUrl = `${githubUrl}/releases/tag/v0.2.0`; +const releaseUrl = `${githubUrl}/releases/tag/v0.3.0`; export default function Home() { return ( @@ -24,12 +24,13 @@ export default function Home() { Your messages already know how you write.

- Turn your private iMessage history into contact-aware style profiles - an agent can use to draft unsent replies in your voice. + Turn private messaging history from Messages and your connected + accounts into contact-aware style profiles an agent can use to draft + unsent replies in your voice.

- $ messagelikeme ingest imessage + $ wrench beeper export-message-like-me --auth beeper-main --output /private/export

-

✓ corpus stored locally

+

✓ private source bundle written

- $ messagelikeme inspect tempo <contact-id> + $ messagelikeme ingest bundle --input /private/export

-

✓ response shape ready

+

✓ source-aware history merged

@@ -85,10 +86,10 @@ export default function Home() {

01 / ingest

Read stable local copies.

- Import Messages and optional Contacts data without opening the - source databases for mutation. + Import Messages and optional Contacts directly, or merge a + private source-aware bundle exported by Wrench from Beeper.

- messagelikeme ingest imessage + messagelikeme ingest bundle --input /private/export

02 / understand

@@ -136,7 +137,8 @@ export default function Home() {

Evidence for a draft.

Message Like Me measures your outgoing prose and delivery shape for - one person, then gives your agent a bounded, inspectable profile. + one person across imported services, then gives your agent a + bounded, inspectable profile.

@@ -167,15 +169,16 @@ export default function Home() {
-

1bun add --global github:hraness/message-like-me#v0.2.0

+

1bun add --global github:hraness/message-like-me#v0.3.0

2messagelikeme skill install

3messagelikeme ingest imessage

+

4messagelikeme ingest bundle --input /private/export

diff --git a/site/app/readme.generated.ts b/site/app/readme.generated.ts index 6a1b34d..caa07cd 100644 --- a/site/app/readme.generated.ts +++ b/site/app/readme.generated.ts @@ -1,2 +1,2 @@ // Generated from ../README.md by scripts/sync-readme.ts. -export const readmeHtml = "

Message Like Me

\n

A local-first CLI and Agent Skill for studying your private iMessage history\nand drafting messages that sound like you.

\n

Message Like Me turns a local Messages database into deterministic conversation\nmetrics, bounded study packets, and reusable style profiles. Its Agent Skill\nteaches Codex, Claude, and other coding agents how to interpret those local\nartifacts and draft unsent replies in your voice.

\n

The CLI does not call an AI service, authenticate with a product account, send\nmessages, or operate Messages. The agent already running the skill supplies the\nsemantic analysis and drafting judgment.

\n

This is an evidence layer for relationship-aware drafting, not a digital clone.\nIt does not train a model, represent your identity, infer your beliefs, or claim\nthat a draft is what you would have written. Your current meaning, facts, and\nintent outrank historical style.

\n

Install

\n

Message Like Me requires Bun 1.3.14 or newer. Install the immutable public\nrelease from GitHub, then install the Agent Skill:

\n
bun add --global github:hraness/message-like-me#v0.2.0\nmessagelikeme skill install\n
\n

Start a new agent session after installing the skill. The default target is\nCodex at user scope. Other supported targets and project-local installation are\navailable explicitly:

\n
messagelikeme skill install --target claude\nmessagelikeme skill install --target agents --scope project\nmessagelikeme skill path\n
\n

Message Like Me is distributed directly through GitHub and is not published to\nnpm.

\n

Start with your local history

\n

Initialize the private data store and inspect its location:

\n
messagelikeme init\nmessagelikeme doctor --json\n
\n

On macOS, the default store is:

\n
~/Library/Application Support/Message Like Me/\n
\n

The directory is private to the current user. It contains a local SQLite\ndatabase, stored profiles, and a private installation key used to derive\nstable pseudonymous IDs. Study packets are written only to the explicit path\nyou choose. You can put the store elsewhere by placing\n--data-dir /absolute/private/path before the command.

\n

Import the current user's iMessage database:

\n
messagelikeme ingest imessage --json\n
\n

The default source is the current user's Messages chat.db. Use --database\nonly to name another caller-owned physical database:

\n
messagelikeme ingest imessage --database /absolute/path/to/chat.db --json\n
\n

Ingestion validates the source schema and ownership, makes a stable private\ncopy of the database and its transactional sidecars, and opens only that copy\nwith SQLite. It does not change Messages, chat.db, or its sidecars. macOS may\nrequire permission for the terminal or agent host to read Messages data.

\n

Optionally enrich and join direct conversations with private identities from\nmacOS Contacts:

\n
messagelikeme ingest contacts --json\n
\n

The default source is the current user's AddressBook directory. An explicit\nabsolute AddressBook root, Sources directory, store directory, or\nAddressBook-vN.abcddb file can be selected with --addressbook:

\n
messagelikeme ingest contacts \\\n  --addressbook /absolute/path/to/AddressBook \\\n  --json\n
\n

Contacts ingest may run before or after iMessage ingest. It reads only bounded\nname, email, and phone fields from a stable private copy. Exact normalized\nemail or phone handles can join several one-to-one iMessage, SMS, and email\nthreads for the same AddressBook person into one analysis scope. Existing\nconversation IDs remain aliases for that person scope. Shared handles remain\nambiguous, local phone numbers never gain a guessed country code, unmatched\nthreads stay separate, and groups are never collapsed to one person. Contact\nlabels have their own revision, so a rename does not stale a messaging-style\nprofile. messagelikeme doctor reports local aggregate state without asking\nfor an account or credential.

\n

Inspect behavior without exposing prose

\n

Contact listings and aggregate views omit private labels, handles, and message\nbodies by default:

\n
messagelikeme contacts list --min-outgoing 20 --json\nmessagelikeme contacts show <contact-id> --json\nmessagelikeme inspect tempo <contact-id> --session-gap 28800 --burst-gap 300 --json\nmessagelikeme inspect sessions <contact-id> --limit 20 --json\n
\n

The metrics cover conversation start and end, message counts, incoming and\noutgoing turns, within-session response latency, single-message versus\nmulti-message replies, surface prose features, multi-point response contexts,\nreactions, and explicit reply use. Incoming messages establish what you were\nresponding to; they are never counted as examples of your writing style.\nSession and burst gaps are configurable seconds and are recorded with each\nresult. They are segmentation choices, not universal facts about conversation.

\n

Pass --private to contacts list or contacts show only when you need to\nresolve a pseudonymous contact to its local private label or participants.

\n

When you already know the complete Contacts label, resolve only that exact\nprivate name instead of listing every label:

\n
messagelikeme contacts resolve "Exact Contact Name" --private --json\n
\n

Resolution is normalized for case and Unicode representation, but it does not\nperform prefix, substring, phonetic, or fuzzy matching. It returns only direct\nperson scopes and labels, never handles or message bodies.

\n

Build a style profile

\n

Aggregate metrics cannot explain why a short burst works in one context or why\na longer single message appears in another. For that semantic work, prepare a\nsmall, diverse study packet at an explicit private path:

\n
messagelikeme study prepare <contact-id> \\\n  --output /absolute/private/path/study.json \\\n  --before 2026-08-01T00:00:00.000Z \\\n  --limit 24 \\\n  --json\n
\n

study prepare and evaluate prepare are the only commands that write bounded\nmessage bodies outside the private database. Their outputs are mode 0600.\nA study packet contains incoming context and outgoing responses selected across\ndifferent response shapes; it is not a full transcript export. By default,\neach body is capped at 4 KiB, each example keeps at most 12 text messages per\ndirection, and the entire packet keeps at most 256 KiB of body text. Packet\ncoverage fields report every truncation or omission explicitly.

\n

Keep the JSON receipt with the analysis. Its packetSha256 binds the finished\nprofile to these exact packet bytes; the packet does not contain its own digest.

\n

--after is inclusive and --before is exclusive. Temporal bounds let you\nreserve later conversations for evaluation. Invoke $message-like-me in your\nagent and ask it to analyze that contact. The skill separates measured facts\nfrom inferred patterns, covers prose and tempo, studies how several inbound\npoints are handled, and treats reply links and tapbacks separately from written\ntext.

\n

The agent writes a schema-version-two profile and asks the CLI to validate and\nstore it:

\n
messagelikeme profile apply /absolute/private/path/profile.json --json\nmessagelikeme profile show <contact-id> --json\n
\n

A version-two profile records the global corpus revision for provenance, a\nperson-and-window-specific evidence revision for validity, the exact\nstudy-packet SHA-256, and the packet's non-body evidence manifest. Measured and\ninferred claims cite valid packet example IDs and record counterexamples,\nsupport counts, confidence, and drafting consequences. Messages for someone\nelse or outside the studied time window do not stale it; changes inside its\nactual evidence do.

\n

Export a profile only when you need an explicit private copy:

\n
messagelikeme profile export <contact-id> --output /absolute/private/path/profile.json\n
\n

Version-one profiles remain readable for migration, but new analyses should use\nschema/style-profile-v2.schema.json.

\n

Audit against later conversations

\n

Prepare a separate prompt and reference set from conversations after the study\ncutoff:

\n
messagelikeme evaluate prepare <contact-id> \\\n  --after 2026-08-01T00:00:00.000Z \\\n  --prompt-output /absolute/private/path/evaluation-prompts.json \\\n  --reference-output /absolute/private/path/evaluation-references.json \\\n  --json\n
\n

Give the agent only the prompt file and fix one candidate bubble sequence per\ncase before opening the reference file. Then compare intent coverage, factual\nmeaning, prose, bubble shape, explicit replies, privacy leakage, and\ncalibration. The files support a blind workflow but do not enforce one, and the\nhistorical response is one observation rather than a unique correct answer.\nThe CLI deliberately does not collapse these dimensions into a universal\nfidelity score. See the methodology.

\n

Draft an unsent reply

\n

Ask an agent with the installed $message-like-me skill to draft for a\npseudonymous contact. The compact deterministic context is available through:

\n
messagelikeme context <contact-id> --json\n
\n

The skill preserves your intended meaning, selects the applicable profile,\nand can express the result as one message or a realistic sequence of separate\nbubbles. It uses explicit replies only when your evidence and the current\ncontext support them.

\n

Drafting ends with text in the agent task. Message Like Me has no send, react,\nschedule, or messaging-application command.

\n

Command reference

\n

Run messagelikeme --help for the checked grammar. The public surfaces are:

\n
messagelikeme init [--json]\nmessagelikeme ingest imessage [--database PATH] [--json]\nmessagelikeme ingest contacts [--addressbook PATH] [--json]\nmessagelikeme contacts list [--min-outgoing N] [--limit N] [--private] [--json]\nmessagelikeme contacts show CONTACT_ID [--private] [--json]\nmessagelikeme contacts resolve QUERY --private [--limit N] [--json]\nmessagelikeme inspect tempo CONTACT_ID [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme inspect sessions CONTACT_ID [--limit N] [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme study prepare CONTACT_ID --output FILE [--limit N]\n  [--after ISO_TIMESTAMP] [--before ISO_TIMESTAMP]\n  [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme evaluate prepare CONTACT_ID --after ISO_TIMESTAMP\n  --prompt-output FILE --reference-output FILE [--before ISO_TIMESTAMP]\n  [--limit N] [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme profile apply FILE [--json]\nmessagelikeme profile show CONTACT_ID [--json]\nmessagelikeme profile export CONTACT_ID --output FILE [--json]\nmessagelikeme context CONTACT_ID [--json]\nmessagelikeme skill path [--json]\nmessagelikeme skill install [--target codex|claude|agents]\n  [--scope user|project] [--project PATH] [--force] [--json]\nmessagelikeme doctor [--json]\n
\n

Place global --data-dir PATH before the command.

\n

Privacy model

\n
    \n
  • The original chat.db and AddressBook databases remain authoritative.\nSQLite opens only stable private copies, never the source files or sidecars.
  • \n
  • The normalized corpus, profiles, and installation key stay in a private local\nstore with owner-only permissions.
  • \n
  • Stable contact, conversation, and message IDs are derived with a private\nper-install HMAC key. Pseudonymous IDs are not encryption.
  • \n
  • Aggregate commands omit bodies and private labels. Study and evaluation\npackets are bounded, explicit body-bearing exports.
  • \n
  • Message text never goes to a Message Like Me server. There is no service,\naccount, auth flow, analytics client, or network-backed model call.
  • \n
  • Opening a study packet makes its bounded excerpts visible to the agent\nenvironment already running the skill. Use an agent environment whose data\nhandling you accept; the CLI cannot make a hosted agent local.
  • \n
  • Public fixtures are synthetic. Private corpora, profiles, packets, and drafts\ndo not belong in Git, issues, logs, packages, or examples.
  • \n
  • A draft is never sent.
  • \n
\n

Read SECURITY.md before integrating the library into another\ntool or handling a private packet outside the CLI. The\nmethodology defines every unit and evidence boundary;\nthe research review documents papers, neighboring OSS, and\nthe claims this project does not make.

\n

TypeScript library

\n

The package exports the versioned corpus, metrics, study-packet, and profile\ntypes plus deterministic canonical JSON and SHA-256 helpers:

\n
import type { ContactMetrics, StyleProfileV2 } from "@hraness/message-like-me"\nimport { canonicalJson, sha256 } from "@hraness/message-like-me"\n
\n

The library does not start the CLI, inspect Messages or Contacts, connect to a\nnetwork, or send a draft merely because it is imported.

\n

Development

\n
bun install --frozen-lockfile --ignore-scripts\nbun run check\n
\n

Tests use synthetic Messages and AddressBook databases plus synthetic\nconversations. Never add a real message, handle, group title, attachment,\ncontact record, private path, or derived profile to a fixture.

\n

The canonical repository is\nhraness/message-like-me.\nThe informational project page is\nmessagelikeme.com. The CLI does not connect to\nthe site, and the site never receives message or contact data.

\n

License

\n

MIT.

\n"; +export const readmeHtml = "

Message Like Me

\n

A local-first CLI and Agent Skill for studying private messaging history and\ndrafting messages that sound like you.

\n

Message Like Me turns private local messaging history into deterministic\nconversation metrics, bounded study packets, and reusable style profiles. It\nreads native iMessage history and strict local source bundles, including\nmulti-account Beeper exports produced through Wrench. Its Agent Skill teaches\nCodex, Claude, and other coding agents how to interpret those local artifacts\nand draft unsent replies in your voice.

\n

The CLI does not call an AI service, authenticate with a product account, send\nmessages, or operate Messages. The agent already running the skill supplies the\nsemantic analysis and drafting judgment.

\n

This is an evidence layer for relationship-aware drafting, not a digital clone.\nIt does not train a model, represent your identity, infer your beliefs, or claim\nthat a draft is what you would have written. Your current meaning, facts, and\nintent outrank historical style.

\n

Install

\n

Message Like Me requires Bun 1.3.14 or newer. Install the immutable public\nrelease from GitHub, then install the Agent Skill:

\n
bun add --global github:hraness/message-like-me#v0.3.0\nmessagelikeme skill install\n
\n

Start a new agent session after installing the skill. The default target is\nCodex at user scope. Other supported targets and project-local installation are\navailable explicitly:

\n
messagelikeme skill install --target claude\nmessagelikeme skill install --target agents --scope project\nmessagelikeme skill path\n
\n

Message Like Me is distributed directly through GitHub and is not published to\nnpm.

\n

Start with private local history

\n

Initialize the private data store and inspect its location:

\n
messagelikeme init\nmessagelikeme doctor --json\n
\n

On macOS, the default store is:

\n
~/Library/Application Support/Message Like Me/\n
\n

The directory is private to the current user. It contains a local SQLite\ndatabase, stored profiles, and a private installation key used to derive\nstable pseudonymous IDs. Study packets are written only to the explicit path\nyou choose. You can put the store elsewhere by placing\n--data-dir /absolute/private/path before the command.

\n

Import the current user's iMessage database:

\n
messagelikeme ingest imessage --json\n
\n

The default source is the current user's Messages chat.db. Use --database\nonly to name another caller-owned physical database:

\n
messagelikeme ingest imessage --database /absolute/path/to/chat.db --json\n
\n

Ingestion validates the source schema and ownership, makes a stable private\ncopy of the database and its transactional sidecars, and opens only that copy\nwith SQLite. It does not change Messages, chat.db, or its sidecars. macOS may\nrequire permission for the terminal or agent host to read Messages data.

\n

To study accounts connected through Beeper, first ask Wrench to create a new\nprivate Message Like Me bundle:

\n
wrench beeper export-message-like-me \\\n  --auth <beeper-auth-id> \\\n  --output /absolute/private/path/beeper-bundle \\\n  --json\n
\n

The optional --limit-chats, --limit-messages, and --max-participants\nflags lower the export bounds. The output path must be a normalized absolute\npath to a directory that does not already exist. Wrench uses the pinned local\nBeeper CLI export without attachment bytes, writes a mode-0700 directory\nwith mode-0600 files, and writes manifest.json last. Provider URLs and\ncredentials are excluded. Message Like Me does not receive the Beeper\ncredential and does not call Beeper or Wrench itself.

\n

Ingest the finished directory, then inspect its redacted source health:

\n
messagelikeme ingest bundle --input /absolute/private/path/beeper-bundle --json\nmessagelikeme sources list --json\nmessagelikeme sources show <source-id> --json\n
\n

The importer verifies the fixed version-one inventory, canonical UTF-8 NDJSON,\nrecord and byte bounds, owner-only permissions, artifact digests, and manifest\ndigest before changing the store. One bundle may contain several connected\naccounts and networks; each becomes a separate source namespace. Native\niMessage and prior bundle sources remain alongside it.

\n

The complete interchange, integrity, identity, and reimport laws are in the\nversion-one local message bundle contract.

\n

Beeper exports describe bounded local observations. A later bounded export\nthat omits an older record does not delete retained history. Explicit deletion,\nremoval, replacement, and tombstone records suppress their target, and a later\nreappearance restores it. Older snapshots cannot overwrite newer state. Use\nsources show <source-id> --private --json only when you deliberately need the\nprivate provider account and source metadata.

\n

Optionally enrich and join direct conversations with private identities from\nmacOS Contacts:

\n
messagelikeme ingest contacts --json\n
\n

The default source is the current user's AddressBook directory. An explicit\nabsolute AddressBook root, Sources directory, store directory, or\nAddressBook-vN.abcddb file can be selected with --addressbook:

\n
messagelikeme ingest contacts \\\n  --addressbook /absolute/path/to/AddressBook \\\n  --json\n
\n

Contacts ingest may run before or after any message source. It reads only\nbounded name, email, and phone fields from a stable private copy. Exact\nnormalized email or E.164 phone handles can join several one-to-one threads\nfor the same AddressBook person into one analysis scope. A bundle conversation\nis eligible only when the producer positively marks its direct participant\nroster complete. Existing conversation IDs remain aliases for that person\nscope. Shared handles remain ambiguous, local phone numbers never gain a\nguessed country code, unmatched threads stay separate, and groups are never\ncollapsed to one person. Contact labels have their own revision, so a rename\ndoes not stale a messaging-style profile. messagelikeme doctor reports local\naggregate state without asking for an account or credential.

\n

Inspect behavior without exposing prose

\n

Contact listings and aggregate views omit private labels, handles, and message\nbodies by default:

\n
messagelikeme contacts list --min-outgoing 20 --json\nmessagelikeme contacts show <contact-id> --json\nmessagelikeme inspect tempo <contact-id> --session-gap 28800 --burst-gap 300 --json\nmessagelikeme inspect sessions <contact-id> --limit 20 --json\n
\n

The metrics cover conversation start and end, message counts, incoming and\noutgoing turns, within-session response latency, single-message versus\nmulti-message replies, surface prose features, multi-point response contexts,\nreactions, and explicit reply use. Incoming messages establish what you were\nresponding to; they are never counted as examples of your writing style.\nSessions, bursts, and response episodes never cross a source conversation\nboundary. Person scopes spanning several apps expose a sorted services\nbreakdown instead of hiding the mixed-channel evidence behind a null service.\nReactions with no provider timestamp still contribute to reaction counts and\ndirection, but never to temporal metrics. Session and burst gaps are\nconfigurable seconds and are recorded with each result. They are segmentation\nchoices, not universal facts about conversation.

\n

Pass --private to contacts list or contacts show only when you need to\nresolve a pseudonymous contact to its local private label or participants.

\n

When you already know the complete Contacts label, resolve only that exact\nprivate name instead of listing every label:

\n
messagelikeme contacts resolve "Exact Contact Name" --private --json\n
\n

Resolution is normalized for case and Unicode representation, but it does not\nperform prefix, substring, phonetic, or fuzzy matching. It returns only direct\nperson scopes and labels, never handles or message bodies.

\n

Build a style profile

\n

Aggregate metrics cannot explain why a short burst works in one context or why\na longer single message appears in another. For that semantic work, prepare a\nsmall, diverse study packet at an explicit private path:

\n
messagelikeme study prepare <contact-id> \\\n  --output /absolute/private/path/study.json \\\n  --before 2026-08-01T00:00:00.000Z \\\n  --limit 24 \\\n  --json\n
\n

study prepare and evaluate prepare are the only commands that write bounded\nmessage bodies outside the private database. Their outputs are mode 0600.\nA study packet contains incoming context and outgoing responses selected across\ndifferent response shapes; it is not a full transcript export. By default,\neach body is capped at 4 KiB, each example keeps at most 12 text messages per\ndirection, and the entire packet keeps at most 256 KiB of body text. Packet\ncoverage fields report every truncation or omission explicitly.

\n

Keep the JSON receipt with the analysis. Its packetSha256 binds the finished\nprofile to these exact packet bytes; the packet does not contain its own digest.

\n

--after is inclusive and --before is exclusive. Temporal bounds let you\nreserve later conversations for evaluation. Invoke $message-like-me in your\nagent and ask it to analyze that contact. The skill separates measured facts\nfrom inferred patterns, covers prose and tempo, studies how several inbound\npoints are handled, and treats reply links and tapbacks separately from written\ntext.

\n

The agent writes a schema-version-two profile and asks the CLI to validate and\nstore it:

\n
messagelikeme profile apply /absolute/private/path/profile.json --json\nmessagelikeme profile show <contact-id> --json\n
\n

A version-two profile records the global corpus revision for provenance, a\nperson-and-window-specific evidence revision for validity, the exact\nstudy-packet SHA-256, and the packet's non-body evidence manifest. Measured and\ninferred claims cite valid packet example IDs and record counterexamples,\nsupport counts, confidence, and drafting consequences. Messages for someone\nelse or outside the studied time window do not stale it; changes inside its\nactual evidence do.

\n

Export a profile only when you need an explicit private copy:

\n
messagelikeme profile export <contact-id> --output /absolute/private/path/profile.json\n
\n

Version-one profiles remain readable for migration, but new analyses should use\nschema/style-profile-v2.schema.json.

\n

Audit against later conversations

\n

Prepare a separate prompt and reference set from conversations after the study\ncutoff:

\n
messagelikeme evaluate prepare <contact-id> \\\n  --after 2026-08-01T00:00:00.000Z \\\n  --prompt-output /absolute/private/path/evaluation-prompts.json \\\n  --reference-output /absolute/private/path/evaluation-references.json \\\n  --json\n
\n

Give the agent only the prompt file and fix one candidate bubble sequence per\ncase before opening the reference file. Then compare intent coverage, factual\nmeaning, prose, bubble shape, explicit replies, privacy leakage, and\ncalibration. The files support a blind workflow but do not enforce one, and the\nhistorical response is one observation rather than a unique correct answer.\nThe CLI deliberately does not collapse these dimensions into a universal\nfidelity score. See the methodology.

\n

Draft an unsent reply

\n

Ask an agent with the installed $message-like-me skill to draft for a\npseudonymous contact. The compact deterministic context is available through:

\n
messagelikeme context <contact-id> --json\n
\n

The skill preserves your intended meaning, selects the applicable profile,\nand can express the result as one message or a realistic sequence of separate\nbubbles. It uses explicit replies only when your evidence and the current\ncontext support them.

\n

Drafting ends with text in the agent task. Message Like Me has no send, react,\nschedule, or messaging-application command.

\n

Command reference

\n

Run messagelikeme --help for the checked grammar. The public surfaces are:

\n
messagelikeme init [--json]\nmessagelikeme ingest imessage [--database PATH] [--json]\nmessagelikeme ingest contacts [--addressbook PATH] [--json]\nmessagelikeme ingest bundle --input ABS_PATH [--json]\nmessagelikeme sources list [--private] [--json]\nmessagelikeme sources show SOURCE_ID [--private] [--json]\nmessagelikeme contacts list [--min-outgoing N] [--limit N] [--private] [--json]\nmessagelikeme contacts show CONTACT_ID [--private] [--json]\nmessagelikeme contacts resolve QUERY --private [--limit N] [--json]\nmessagelikeme inspect tempo CONTACT_ID [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme inspect sessions CONTACT_ID [--limit N] [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme study prepare CONTACT_ID --output FILE [--limit N]\n  [--after ISO_TIMESTAMP] [--before ISO_TIMESTAMP]\n  [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme evaluate prepare CONTACT_ID --after ISO_TIMESTAMP\n  --prompt-output FILE --reference-output FILE [--before ISO_TIMESTAMP]\n  [--limit N] [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme profile apply FILE [--json]\nmessagelikeme profile show CONTACT_ID [--json]\nmessagelikeme profile export CONTACT_ID --output FILE [--json]\nmessagelikeme context CONTACT_ID [--json]\nmessagelikeme skill path [--json]\nmessagelikeme skill install [--target codex|claude|agents]\n  [--scope user|project] [--project PATH] [--force] [--json]\nmessagelikeme doctor [--json]\n
\n

Place global --data-dir PATH before the command.

\n

Privacy model

\n
    \n
  • The original chat.db and AddressBook databases remain authoritative.\nSQLite opens only stable private copies, never the source files or sidecars.
  • \n
  • Source bundles remain private caller-owned inputs. Import verifies their\nfixed inventory, canonical bytes, digests, bounds, and owner-only modes.
  • \n
  • The normalized corpus, profiles, and installation key stay in a private local\nstore with owner-only permissions.
  • \n
  • Stable source, contact, participant, conversation, message, and reaction IDs\nare derived with a private per-install HMAC key. Pseudonymous IDs are not\nencryption.
  • \n
  • Aggregate commands omit bodies and private labels. Study and evaluation\npackets are bounded, explicit body-bearing exports.
  • \n
  • Message text never goes to a Message Like Me server. There is no service,\naccount, auth flow, analytics client, or network-backed model call.
  • \n
  • Opening a study packet makes its bounded excerpts visible to the agent\nenvironment already running the skill. Use an agent environment whose data\nhandling you accept; the CLI cannot make a hosted agent local.
  • \n
  • Public fixtures are synthetic. Private corpora, profiles, packets, and drafts\ndo not belong in Git, issues, logs, packages, or examples.
  • \n
  • A draft is never sent.
  • \n
\n

Read SECURITY.md before integrating the library into another\ntool or handling a private packet outside the CLI. The\nmethodology defines every unit and evidence boundary;\nthe research review documents papers, neighboring OSS, and\nthe claims this project does not make.

\n

TypeScript library

\n

The package exports the versioned corpus, metrics, study-packet, and profile\ntypes plus deterministic canonical JSON and SHA-256 helpers:

\n
import type { ContactMetrics, StyleProfileV2 } from "@hraness/message-like-me"\nimport { canonicalJson, sha256 } from "@hraness/message-like-me"\n
\n

The library does not start the CLI, inspect Messages or Contacts, connect to a\nnetwork, or send a draft merely because it is imported.

\n

Development

\n
bun install --frozen-lockfile --ignore-scripts\nbun run check\n
\n

Tests use synthetic Messages and AddressBook databases plus synthetic source\nbundles and conversations. Never add a real message, handle, group title,\nattachment, contact record, private path, or derived profile to a fixture.

\n

The canonical repository is\nhraness/message-like-me.\nThe informational project page is\nmessagelikeme.com. The CLI does not connect to\nthe site, and the site never receives message or contact data.

\n

License

\n

MIT.

\n"; diff --git a/site/package.json b/site/package.json index d4e398b..6e06860 100644 --- a/site/package.json +++ b/site/package.json @@ -1,6 +1,6 @@ { "name": "message-like-me-site", - "version": "0.2.0", + "version": "0.3.0", "private": true, "engines": { "node": ">=22.13.0" diff --git a/skills/message-like-me/SKILL.md b/skills/message-like-me/SKILL.md index 0cabd30..dd09689 100644 --- a/skills/message-like-me/SKILL.md +++ b/skills/message-like-me/SKILL.md @@ -63,6 +63,21 @@ database with `messagelikeme ingest imessage --json`; pass `--database` only when the user names a different source. The ingest is read-only. It stores a private normalized corpus and aggregate metrics without changing `chat.db`. +When the user supplies a finished Wrench/Beeper Message Like Me bundle, ingest +only its normalized absolute directory path: + +```sh +messagelikeme ingest bundle --input --json +messagelikeme sources list --json +``` + +Do not request or handle the Beeper credential, call Beeper directly, improvise +a provider parser, or open the bundle's NDJSON files. Wrench owns provider +capture; Message Like Me owns strict verification, normalization, and local +analysis. Use `sources show --json` for redacted completeness and +health. Add `--private` only when the user's task requires provider account +metadata. + When the user wants AddressBook names attached to direct conversations, run `messagelikeme ingest contacts --json`. Pass `--addressbook` only for an explicit alternative AddressBook root, source directory, or database. The @@ -70,10 +85,13 @@ optional enrichment is also read-only, may run before or after Messages ingestion, and keeps ambiguous methods and group conversations unresolved. When Contacts supplies an unambiguous exact handle match, the CLI can combine -that person's direct Messages conversations into one pseudonymous `person_...` -analysis scope. Unmatched conversations and groups remain separate. Treat each -scope as evidence about messaging with that observed person or conversation, -not as a label for the relationship or a complete model of either participant. +that person's complete-roster direct conversations across message sources into +one pseudonymous `person_...` analysis scope. Unmatched conversations, +incomplete rosters, and groups remain separate. Treat each scope as evidence +about messaging with that observed person or conversation, not as a label for +the relationship or a complete model of either participant. Inspect the +`services` breakdown before applying a multi-app profile as though it described +one channel. Use the CLI's aggregate views before requesting message text. Ask for the narrowest bounded study packet that answers the question. Prefer stable local diff --git a/skills/message-like-me/references/analysis.md b/skills/message-like-me/references/analysis.md index 63e412b..0a09a14 100644 --- a/skills/message-like-me/references/analysis.md +++ b/skills/message-like-me/references/analysis.md @@ -12,9 +12,11 @@ segmentation parameters, and exclusions. Check whether the evidence spans enough conversations and contexts to support the requested claim. An AddressBook-matched `person_...` scope can combine several conservatively -matched direct Messages conversations with one person. An unmatched contact ID -or a group remains a conversation scope. Analyze the observed messaging scope -without inferring a relationship category, importance, or status. +matched complete-roster direct conversations with one person across message +sources. An unmatched contact ID, incomplete roster, or group remains a +conversation scope. Inspect the source and `services` breakdown before +generalizing across apps. Analyze the observed messaging scope without +inferring a relationship category, importance, or status. Start with aggregate metrics. Open bounded text samples only for questions the metrics cannot answer, such as how the user acknowledges emotion, resolves @@ -101,6 +103,10 @@ Examine: - explicit reply-link frequency and the situations where replies are used; - tapbacks as lightweight acknowledgements, separate from written replies. +Reactions with no provider timestamp remain valid count and direction evidence. +Do not place them in chronological order, a session, or a response episode, and +do not synthesize a reaction time. + Do not describe within-session response latency as an obligation, promise, availability signal, or general preference. The sample excludes incoming bursts without a later outgoing burst in the same session and may be shaped by diff --git a/skills/message-like-me/references/privacy.md b/skills/message-like-me/references/privacy.md index a003937..3f2a3f4 100644 --- a/skills/message-like-me/references/privacy.md +++ b/skills/message-like-me/references/privacy.md @@ -7,11 +7,15 @@ sensitive local data. ## Data boundary - Use the `messagelikeme` CLI for ingestion and inspection. Do not open, copy, - transform, or query the live Messages or AddressBook databases through an - improvised script. + transform, or query the live Messages or AddressBook databases or a private + message bundle through an improvised script. - Keep the original `chat.db` and AddressBook stores authoritative. Ingestion is read-only and must not change Messages, Contacts, attachments, or database sidecars. +- Treat caller-owned provider bundles as private source observations. Check + their state through `messagelikeme sources list|show`; do not parse their + manifest or NDJSON records in agent context. The bundle must not contain a + provider credential, but it still contains private message and account data. - Do not send message data to a model API, hosted service, analytics system, remote MCP server, or network endpoint. The agent already executing this skill performs the semantic work directly in its current context. diff --git a/src/args.ts b/src/args.ts index 610eb66..a68c0cf 100644 --- a/src/args.ts +++ b/src/args.ts @@ -7,6 +7,7 @@ const VALUE_OPTIONS = new Set([ "burst-gap", "data-dir", "database", + "input", "limit", "min-outgoing", "output", diff --git a/src/bundle.test.ts b/src/bundle.test.ts new file mode 100644 index 0000000..f4e0861 --- /dev/null +++ b/src/bundle.test.ts @@ -0,0 +1,640 @@ +import { describe, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { readMessageBundle } from "./bundle.ts"; +import { canonicalJson, sha256 } from "./canonical-json.ts"; +import { CliError } from "./errors.ts"; +import { LocalStore } from "./store.ts"; +import { + syntheticBundleRecords, + writeSyntheticMessageBundle, +} from "./test-bundle-fixture.ts"; + +const TEST_KEY = "synthetic-bundle-test-key-32-bytes"; +const GOLDEN_MANIFEST_SHA256 = "e46f4a524d53f849cfac594fb5bc8cf28e7a9743c138039b81a0aad4ff4830ef"; +const GOLDEN_FILES = Object.freeze([ + "accounts.ndjson", + "participants.ndjson", + "conversations.ndjson", + "messages.ndjson", + "reactions.ndjson", + "tombstones.ndjson", + "manifest.json", +]); + +async function materializeWrenchGoldenBundle(parent: string): Promise { + const source = join(import.meta.dir, "fixtures", "beeper-message-like-me-v1"); + const target = join(parent, "beeper-message-like-me-v1"); + await mkdir(target, { mode: 0o700 }); + await chmod(target, 0o700); + for (const file of GOLDEN_FILES) { + const bytes = await readFile(join(source, file)); + await writeFile(join(target, file), bytes, { mode: 0o600 }); + await chmod(join(target, file), 0o600); + } + return realpath(target); +} + +async function replaceArtifactBytes( + bundlePath: string, + artifactPath: string, + bytes: Uint8Array, +): Promise { + await writeFile(join(bundlePath, artifactPath), bytes, { mode: 0o600 }); + await chmod(join(bundlePath, artifactPath), 0o600); + const manifestPath = join(bundlePath, "manifest.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { + artifacts: Array<{ path: string; records: number; bytes: number; sha256: string }>; + integrity: { algorithm: "sha256"; bundleSha256: string }; + [key: string]: unknown; + }; + const artifact = manifest.artifacts.find(({ path }) => path === artifactPath); + if (artifact === undefined) throw new Error("Synthetic artifact is absent from its manifest"); + artifact.bytes = bytes.byteLength; + artifact.sha256 = sha256(bytes); + const { integrity: _integrity, ...projection } = manifest; + manifest.integrity = { algorithm: "sha256", bundleSha256: sha256(canonicalJson(projection)) }; + await writeFile(manifestPath, `${canonicalJson(manifest)}\n`, { mode: 0o600 }); + await chmod(manifestPath, 0o600); +} + +describe("private local message bundle", () => { + test("imports the exact canonical bundle emitted by Wrench", async () => { + const root = await mkdtemp(join(tmpdir(), "message-like-me-wrench-golden-")); + try { + const vendoredManifest = await readFile(join( + import.meta.dir, + "fixtures", + "beeper-message-like-me-v1", + "manifest.json", + )); + expect(sha256(vendoredManifest)).toBe(GOLDEN_MANIFEST_SHA256); + const path = await materializeWrenchGoldenBundle(root); + const bundle = await readMessageBundle(path, { hmacKey: TEST_KEY }); + expect(bundle.manifestSha256).toBe(GOLDEN_MANIFEST_SHA256); + expect(bundle.sources).toHaveLength(1); + expect(bundle.sources[0]!.source).toMatchObject({ + provider: "beeper", + network: "synthetic", + producer: { id: "beeper-local", version: "1.0.0" }, + coverage: { + kind: "truncated", + reason: "explicit-source-limit", + }, + }); + expect(bundle.sources[0]!.messages[0]).toMatchObject({ + body: "edited synthetic reply", + editedAt: "2026-08-21T15:58:30.000Z", + retractedAt: null, + replyToSourceGuid: "beeper-message:synthetic-external-reply-target", + }); + expect(bundle.sources[0]!.messages[1]).toMatchObject({ + body: null, + retractedAt: "2026-08-21T15:59:00.000Z", + direction: "incoming", + }); + expect(bundle.sources[0]!.reactionFacts).toMatchObject([{ + body: "👍", + reactedAt: null, + direction: "incoming", + }]); + expect(bundle.sources[0]!.deletions).toEqual(expect.arrayContaining([ + expect.objectContaining({ + entityKind: "message", + externalId: "beeper-message:synthetic-deleted", + deletedAt: "2026-08-21T15:59:00.000Z", + }), + ])); + expect(bundle.sources[0]!.deletions?.some(({ externalId }) => + externalId === "beeper-message:synthetic-edited")).toBeFalse(); + + const store = LocalStore.open(join(root, "golden-store.sqlite3")); + try { + store.replaceSources(bundle.sources, "2026-08-21T16:01:00.000Z", TEST_KEY); + expect(store.listSources()).toMatchObject([{ + provider: "beeper", + network: "synthetic", + conversations: 1, + messages: 1, + reactions: 1, + undatedReactions: 1, + coverage: { + kind: "truncated", + reason: "explicit-source-limit", + }, + }]); + } finally { + store.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("verifies and normalizes Wrench's six-artifact contract", async () => { + const root = await mkdtemp(join(tmpdir(), "message-like-me-bundle-")); + try { + const path = await writeSyntheticMessageBundle(root); + const bundle = await readMessageBundle(path, { hmacKey: TEST_KEY }); + expect(bundle.schemaVersion).toBe(1); + expect(bundle.manifestSha256).toMatch(/^[a-f0-9]{64}$/u); + expect(bundle.sources).toHaveLength(1); + const source = bundle.sources[0]!; + expect(source.source).toMatchObject({ + kind: "bundle", + provider: "beeper", + network: "whatsapp", + coverage: { + history: "bounded", + kind: "truncated", + reason: "synthetic-limit", + }, + }); + expect(source.source.id).toMatch(/^source_[a-f0-9]{64}$/u); + expect(source.conversations[0]).toMatchObject({ + group: false, + privateParticipants: ["peer@example.test"], + }); + const outgoing = source.messages.find(({ sourceGuid }) => + sourceGuid === "message-provider-outgoing")!; + expect(outgoing).toMatchObject({ + kind: "text", + body: "Synthetic answer.", + replyToSourceGuid: "message-provider-incoming", + attachmentCount: 1, + }); + expect(source.messageProvenance.find(({ messageId }) => messageId === outgoing.id)) + .toMatchObject({ + replyToExternalId: "message-provider-incoming", + attachments: [{ + kind: "image", + mimeType: "image/png", + fileName: "synthetic.png", + bytes: 1234, + }], + }); + expect(source.messages.find(({ sourceGuid }) => sourceGuid === "message-provider-truncated")) + .toMatchObject({ kind: "text", body: null, bodySource: "unavailable" }); + expect(source.messages.find(({ sourceGuid }) => sourceGuid === "message-provider-deleted")) + .toMatchObject({ body: null, retractedAt: "2026-08-20T12:04:00.000Z" }); + expect(source.deletions).toContainEqual(expect.objectContaining({ + entityKind: "message", + externalId: "message-provider-deleted", + deletedAt: "2026-08-20T12:04:00.000Z", + })); + expect(source.messages.filter(({ kind }) => kind === "reaction")).toHaveLength(1); + expect(source.reactionFacts).toMatchObject([ + { body: "heart", reactedAt: "2026-08-20T12:01:30.000Z", direction: "incoming" }, + { body: "thumbs-up", reactedAt: null, direction: "outgoing" }, + ]); + expect(source.auxiliaryRecords?.filter(({ kind }) => kind === "reaction")).toHaveLength(2); + expect(source.source.warnings).toContain("undated-reactions:1"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("uses fatal UTF-8 decoding before canonical byte comparison", async () => { + const root = await mkdtemp(join(tmpdir(), "message-like-me-bundle-utf8-")); + try { + const path = await writeSyntheticMessageBundle(root); + const accountsPath = join(path, "accounts.ndjson"); + const bytes = Buffer.from(await readFile(accountsPath)); + bytes[10] = 0xff; + await writeFile(accountsPath, bytes, { mode: 0o600 }); + await expect(readMessageBundle(path, { hmacKey: TEST_KEY })).rejects.toThrow("valid UTF-8"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("rejects deeply nested foreign fields before recursive canonicalization", async () => { + const root = await mkdtemp(join(tmpdir(), "message-like-me-bundle-depth-")); + const deepValue = `${"[".repeat(20_000)}0${"]".repeat(20_000)}`; + try { + const artifactPath = await writeSyntheticMessageBundle(root, syntheticBundleRecords(), { + directoryName: "deep-artifact", + }); + const messagesPath = join(artifactPath, "messages.ndjson"); + const original = (await readFile(messagesPath, "utf8")).trimEnd(); + const bytes = Buffer.from(`${original.slice(0, -1)},"unexpected":${deepValue}}\n`, "utf8"); + await replaceArtifactBytes(artifactPath, "messages.ndjson", bytes); + try { + await readMessageBundle(artifactPath, { hmacKey: TEST_KEY }); + throw new Error("Expected deep artifact rejection"); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + expect(error).not.toBeInstanceOf(RangeError); + } + + const manifestBundle = await writeSyntheticMessageBundle(root, syntheticBundleRecords(), { + directoryName: "deep-manifest", + }); + const manifestPath = join(manifestBundle, "manifest.json"); + const originalManifest = (await readFile(manifestPath, "utf8")).trimEnd(); + await writeFile( + manifestPath, + `${originalManifest.slice(0, -1)},"unexpected":${deepValue}}\n`, + { mode: 0o600 }, + ); + await chmod(manifestPath, 0o600); + try { + await readMessageBundle(manifestBundle, { hmacKey: TEST_KEY }); + throw new Error("Expected deep manifest rejection"); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + expect(error).not.toBeInstanceOf(RangeError); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("rejects incomplete identity joins before creating local coordinates", async () => { + const cases: Array) => void; + error: string; + }>> = [ + { + name: "unknown roster participant", + mutate: (records) => { + records.conversation[0]!.participantIds = ["participant-self", "participant-missing"]; + }, + error: "unknown participant", + }, + { + name: "complete direct roster without self", + mutate: (records) => { + records.conversation[0]!.participantIds = ["participant-peer"]; + }, + error: "one self and one non-self participant", + }, + { + name: "complete direct roster with only self", + mutate: (records) => { + records.conversation[0]!.participantIds = ["participant-self"]; + }, + error: "one self and one non-self participant", + }, + { + name: "sender direction mismatch", + mutate: (records) => { + records.message[0]!.direction = "outgoing"; + }, + error: "direction conflicts", + }, + { + name: "duplicate provider coordinate", + mutate: (records) => { + records.participant.push({ + ...records.participant[1]!, + id: "participant-duplicate-local", + }); + }, + error: "repeat a provider identity", + }, + { + name: "reply coordinate mismatch", + mutate: (records) => { + records.message[1]!.replyTo = { + messageId: "message-incoming", + providerId: "different-provider-message", + }; + }, + error: "reply has mismatched target coordinates", + }, + { + name: "reaction coordinate mismatch", + mutate: (records) => { + records.reaction[0]!.messageProviderId = "different-provider-message"; + }, + error: "reaction has mismatched target coordinates", + }, + ]; + for (const [index, candidate] of cases.entries()) { + const root = await mkdtemp(join(tmpdir(), `message-like-me-bundle-join-${index}-`)); + try { + const records = syntheticBundleRecords(); + candidate.mutate(records); + const path = await writeSyntheticMessageBundle(root, records); + await expect(readMessageBundle(path, { hmacKey: TEST_KEY })) + .rejects.toThrow(candidate.error); + } finally { + await rm(root, { recursive: true, force: true }); + } + } + }); + + test("rejects a tombstone whose local and provider coordinates disagree", async () => { + const root = await mkdtemp(join(tmpdir(), "message-like-me-bundle-tombstone-")); + try { + const records = syntheticBundleRecords(); + records.tombstone.push({ + schemaVersion: 1, + kind: "tombstone", + id: "tombstone-local", + accountId: "account-local", + network: "whatsapp", + provenance: { + providerId: "tombstone-provider", + providerRevision: null, + observedAt: "2026-08-20T12:05:00.000Z", + connectedAccountProviderId: "synthetic-connected-account", + }, + entityKind: "message", + entityId: "message-incoming", + entityProviderId: "different-message-provider", + deletedAt: "2026-08-20T12:05:00.000Z", + scope: "remote", + providerRevision: null, + }); + const path = await writeSyntheticMessageBundle(root, records); + await expect(readMessageBundle(path, { hmacKey: TEST_KEY })) + .rejects.toThrow("mismatched message identity"); + const missing = syntheticBundleRecords(); + missing.tombstone.push({ + ...records.tombstone[0]!, + id: "tombstone-missing-local", + provenance: { + providerId: "tombstone-provider-missing", + providerRevision: null, + observedAt: "2026-08-20T12:05:00.000Z", + connectedAccountProviderId: "synthetic-connected-account", + }, + entityId: "private-missing-local-id", + entityProviderId: "private-missing-provider-id", + }); + const missingPath = await writeSyntheticMessageBundle(root, missing, { + directoryName: "missing-tombstone-target", + }); + try { + await readMessageBundle(missingPath, { hmacKey: TEST_KEY }); + throw new Error("Expected unresolved tombstone rejection"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + expect(message).toContain("unknown local message"); + expect(message).not.toContain("private-missing-local-id"); + expect(message).not.toContain("private-missing-provider-id"); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("turns terminal edits and removed reactions into explicit suppressions", async () => { + const root = await mkdtemp(join(tmpdir(), "message-like-me-bundle-terminal-")); + try { + const records = syntheticBundleRecords(); + records.message.push({ + ...records.message[1]!, + id: "message-edit-local", + provenance: { + providerId: "message-provider-edit", + providerRevision: "revision-edit", + observedAt: "2026-08-20T12:05:00.000Z", + connectedAccountProviderId: "synthetic-connected-account", + }, + sentAt: "2026-08-20T12:01:00.000Z", + sortKey: "0002-edit", + body: "Synthetic edited answer.", + replyTo: null, + edit: { + kind: "replacement", + replacesMessageId: "message-outgoing", + replacesProviderId: "message-provider-outgoing", + editedAt: "2026-08-20T12:02:00.000Z", + providerRevision: "revision-edit", + }, + attachments: [], + }); + records.reaction.push({ + ...records.reaction[0]!, + id: "reaction-removed", + provenance: { + providerId: "reaction-provider-removed", + providerRevision: "reaction-revision", + observedAt: "2026-08-20T12:05:00.000Z", + connectedAccountProviderId: "synthetic-connected-account", + }, + reactedAt: null, + state: "removed", + }); + const path = await writeSyntheticMessageBundle(root, records); + const source = (await readMessageBundle(path, { hmacKey: TEST_KEY })).sources[0]!; + expect(source.deletions).toEqual(expect.arrayContaining([ + expect.objectContaining({ + entityKind: "message", + externalId: "message-provider-outgoing", + deletedAt: "2026-08-20T12:02:00.000Z", + }), + expect.objectContaining({ + entityKind: "reaction", + externalId: "reaction-provider-removed", + deletedAt: "2026-08-20T12:05:00.000Z", + }), + ])); + const inPlaceRecords = syntheticBundleRecords(); + inPlaceRecords.message[1]!.edit = { + kind: "in-place", + editedAt: "2026-08-20T12:02:00.000Z", + providerRevision: "revision-in-place", + }; + const inPlacePath = await writeSyntheticMessageBundle(root, inPlaceRecords, { + directoryName: "in-place-edit-bundle", + }); + const inPlace = (await readMessageBundle(inPlacePath, { hmacKey: TEST_KEY })).sources[0]!; + expect(inPlace.messages.find(({ sourceGuid }) => sourceGuid === "message-provider-outgoing")) + .toMatchObject({ editedAt: "2026-08-20T12:02:00.000Z" }); + expect(inPlace.deletions?.some(({ externalId }) => + externalId === "message-provider-outgoing")).toBeFalse(); + + const cyclic = syntheticBundleRecords(); + cyclic.message[0]!.edit = { + kind: "replacement", + replacesMessageId: "message-outgoing", + replacesProviderId: "message-provider-outgoing", + editedAt: "2026-08-20T12:02:00.000Z", + providerRevision: "cycle-a", + }; + cyclic.message[1]!.edit = { + kind: "replacement", + replacesMessageId: "message-incoming", + replacesProviderId: "message-provider-incoming", + editedAt: "2026-08-20T12:02:00.000Z", + providerRevision: "cycle-b", + }; + const cyclicPath = await writeSyntheticMessageBundle(root, cyclic, { + directoryName: "cyclic-edit-bundle", + }); + await expect(readMessageBundle(cyclicPath, { hmacKey: TEST_KEY })) + .rejects.toThrow("contain a cycle"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("validates the private HMAC key before touching bundle input", async () => { + await expect(readMessageBundle("/synthetic/does-not-exist", { hmacKey: "short" })) + .rejects.toThrow("HMAC key"); + }); + + test("orders opaque provider sort keys by code units", async () => { + const root = await mkdtemp(join(tmpdir(), "message-like-me-bundle-order-")); + try { + const records = syntheticBundleRecords(); + records.message[0]!.sortKey = "Z"; + records.message[1]!.sortKey = "a"; + records.message[2]!.sortKey = "b"; + records.message[3]!.sortKey = "c"; + records.message.reverse(); + const path = await writeSyntheticMessageBundle(root, records); + const source = (await readMessageBundle(path, { hmacKey: TEST_KEY })).sources[0]!; + expect(source.messages.filter(({ kind }) => kind !== "reaction").map(({ sourceGuid }) => sourceGuid)) + .toEqual([ + "message-provider-incoming", + "message-provider-outgoing", + "message-provider-truncated", + "message-provider-deleted", + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("keeps one stable source and entity namespace when a network label changes", async () => { + const root = await mkdtemp(join(tmpdir(), "message-like-me-bundle-network-")); + try { + const firstPath = await writeSyntheticMessageBundle(root, syntheticBundleRecords(), { + directoryName: "first-network", + }); + const changedRecords = syntheticBundleRecords(); + for (const values of Object.values(changedRecords)) { + for (const record of values) record.network = "whatsapp-business"; + } + const changedPath = await writeSyntheticMessageBundle(root, changedRecords, { + directoryName: "renamed-network", + createdAt: "2026-08-20T12:06:00.000Z", + }); + const first = (await readMessageBundle(firstPath, { hmacKey: TEST_KEY })).sources[0]!; + const changed = (await readMessageBundle(changedPath, { hmacKey: TEST_KEY })).sources[0]!; + expect(changed.source.id).toBe(first.source.id); + expect(changed.conversations.map(({ id }) => id)).toEqual(first.conversations.map(({ id }) => id)); + expect(changed.messages.map(({ id }) => id).sort()).toEqual(first.messages.map(({ id }) => id).sort()); + + const store = LocalStore.open(join(root, "network-store.sqlite3")); + try { + store.replaceSources([first], "2026-08-20T12:05:30.000Z", TEST_KEY); + store.replaceSources([changed], "2026-08-20T12:06:30.000Z", TEST_KEY); + expect(store.listSources()).toMatchObject([{ + id: first.source.id, + network: "whatsapp-business", + conversations: 1, + messages: 4, + }]); + } finally { + store.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("derives completeness time bounds independently for each account", async () => { + const root = await mkdtemp(join(tmpdir(), "message-like-me-bundle-account-bounds-")); + try { + const records = syntheticBundleRecords(); + records.account.push({ + schemaVersion: 1, + kind: "account", + id: "account-empty", + accountId: "account-empty", + network: "signal", + provenance: { + providerId: "synthetic-connected-account-empty", + providerRevision: null, + observedAt: "2026-08-20T12:05:00.000Z", + connectedAccountProviderId: "synthetic-connected-account-empty", + }, + displayName: "Synthetic Empty Account", + handle: null, + selfParticipantId: "participant-empty-self", + }); + records.participant.push({ + schemaVersion: 1, + kind: "participant", + id: "participant-empty-self", + accountId: "account-empty", + network: "signal", + provenance: { + providerId: "participant-provider-empty-self", + providerRevision: null, + observedAt: "2026-08-20T12:05:00.000Z", + connectedAccountProviderId: "synthetic-connected-account-empty", + }, + displayName: "Synthetic Empty Self", + handle: null, + isSelf: true, + }); + const path = await writeSyntheticMessageBundle(root, records); + const bundle = await readMessageBundle(path, { hmacKey: TEST_KEY }); + expect(bundle.sources).toHaveLength(2); + expect(bundle.sources.find(({ source }) => source.network === "whatsapp")?.source.coverage) + .toMatchObject({ + observedFrom: "2026-08-20T12:00:00.000Z", + observedTo: "2026-08-20T12:03:00.000Z", + }); + expect(bundle.sources.find(({ source }) => source.network === "signal")?.source.coverage) + .toMatchObject({ observedFrom: null, observedTo: null }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("domain-separates a reaction timeline coordinate from an equal message provider ID", async () => { + const root = await mkdtemp(join(tmpdir(), "message-like-me-bundle-cross-kind-id-")); + try { + const records = syntheticBundleRecords(); + const sharedProviderId = records.message[0]!.provenance as Record; + (records.reaction[0]!.provenance as Record).providerId = sharedProviderId.providerId; + const path = await writeSyntheticMessageBundle(root, records); + const source = (await readMessageBundle(path, { hmacKey: TEST_KEY })).sources[0]!; + const rawProviderId = String(sharedProviderId.providerId); + const message = source.messages.find(({ kind, sourceGuid }) => + kind !== "reaction" && sourceGuid === rawProviderId); + const timelineReaction = source.messages.find(({ kind }) => kind === "reaction"); + expect(message).toBeDefined(); + expect(timelineReaction).toBeDefined(); + expect(timelineReaction!.sourceGuid).not.toBe(rawProviderId); + expect(timelineReaction!.sourceGuid.startsWith("\u001freaction-timeline:")).toBeTrue(); + expect(source.reactionFacts?.find(({ id }) => id === timelineReaction!.id)?.externalId) + .toBe(rawProviderId); + expect(source.messageProvenance.find(({ messageId }) => messageId === timelineReaction!.id)) + .toMatchObject({ + externalId: timelineReaction!.sourceGuid, + metadata: { provenance: { providerId: rawProviderId } }, + }); + + const store = LocalStore.open(join(root, "cross-kind-store.sqlite3")); + try { + expect(() => store.replaceSources( + [source], + "2026-08-20T12:06:00.000Z", + TEST_KEY, + )).not.toThrow(); + expect(store.listSources()).toMatchObject([{ + conversations: 1, + messages: 4, + reactions: 2, + undatedReactions: 1, + }]); + } finally { + store.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/bundle.ts b/src/bundle.ts new file mode 100644 index 0000000..0cff404 --- /dev/null +++ b/src/bundle.ts @@ -0,0 +1,1442 @@ +import { createHash, createHmac } from "node:crypto"; +import { constants as fsConstants, createReadStream, type BigIntStats } from "node:fs"; +import { lstat, open, readdir, realpath, type FileHandle } from "node:fs/promises"; +import { isAbsolute, join, resolve } from "node:path"; + +import { canonicalJson, sha256 } from "./canonical-json.ts"; +import { normalizeContactHandle } from "./contacts.ts"; +import { CliError } from "./errors.ts"; +import { + MESSAGE_BUNDLE_SCHEMA_VERSION, + type CorpusAttachmentProvenance, + type CorpusConversation, + type CorpusMessage, + type CorpusMessageProvenance, + type CorpusReactionFact, + type CorpusSourceDeletion, + type CorpusSourceRecord, + type MessageBundleSnapshot, + type SourceCorpusSnapshot, +} from "./types.ts"; + +type JsonObject = Record; +type RecordKind = "account" | "participant" | "conversation" | "message" | "reaction" | "tombstone"; + +const MAX_MANIFEST_BYTES = 1024 * 1024; +const MAX_RECORDS = 500_000; +const MAX_RECORD_BYTES = 2 * 1024 * 1024; +const MAX_TOTAL_BYTES = 512 * 1024 * 1024; +const MAX_ACCOUNTS = 128; +const MAX_IDENTIFIER_BYTES = 1_024; +const MAX_SHORT_TEXT_BYTES = 8 * 1024; +const MAX_BODY_BYTES = 1024 * 1024; +const MAX_PARTICIPANTS = 10_000; +const MAX_ATTACHMENTS = 256; +const MAX_WARNINGS = 128; + +const ARTIFACTS = Object.freeze([ + Object.freeze({ path: "accounts.ndjson", kind: "account" as const }), + Object.freeze({ path: "participants.ndjson", kind: "participant" as const }), + Object.freeze({ path: "conversations.ndjson", kind: "conversation" as const }), + Object.freeze({ path: "messages.ndjson", kind: "message" as const }), + Object.freeze({ path: "reactions.ndjson", kind: "reaction" as const }), + Object.freeze({ path: "tombstones.ndjson", kind: "tombstone" as const }), +]); + +type Provenance = Readonly<{ + providerId: string; + providerRevision: string | null; + observedAt: string; + connectedAccountProviderId: string; +}>; + +type CommonRecord = Readonly<{ + schemaVersion: 1; + kind: RecordKind; + id: string; + accountId: string; + network: string; + provenance: Provenance; +}>; + +type AccountRecord = CommonRecord & Readonly<{ + kind: "account"; + displayName: string | null; + handle: string | null; + selfParticipantId: string; +}>; + +type ParticipantRecord = CommonRecord & Readonly<{ + kind: "participant"; + displayName: string | null; + handle: string | null; + isSelf: boolean; +}>; + +type ConversationRecord = CommonRecord & Readonly<{ + kind: "conversation"; + type: "direct" | "group" | "channel" | "unknown"; + title: string | null; + participantIds: readonly string[]; + participantsComplete: boolean | null; + startedAt: string | null; + lastMessageAt: string | null; +}>; + +type AttachmentRecord = Readonly<{ + kind: "audio" | "document" | "image" | "link" | "sticker" | "video" | "unknown"; + mimeType: string | null; + name: string | null; + sizeBytes: number | null; +}>; + +type MessageRecord = CommonRecord & Readonly<{ + kind: "message"; + conversationId: string; + senderParticipantId: string | null; + direction: "incoming" | "outgoing" | "unknown"; + sentAt: string; + sortKey: string; + body: string | null; + bodyTruncated: boolean | null; + replyTo: Readonly<{ messageId: string | null; providerId: string }> | null; + edit: Readonly<{ + kind: "in-place"; + editedAt: string; + providerRevision: string; + }> | Readonly<{ + kind: "replacement"; + replacesMessageId: string | null; + replacesProviderId: string; + editedAt: string; + providerRevision: string; + }> | null; + deletion: Readonly<{ + state: "revoked" | "deleted-for-me" | "revoked-and-deleted-for-me"; + observedAt: string; + providerRevision: string | null; + }> | null; + attachments: readonly AttachmentRecord[]; +}>; + +type ReactionRecord = CommonRecord & Readonly<{ + kind: "reaction"; + messageId: string | null; + messageProviderId: string; + participantId: string | null; + body: string; + reactedAt: string | null; + state: "active" | "removed"; +}>; + +type TombstoneRecord = CommonRecord & Readonly<{ + kind: "tombstone"; + entityKind: "conversation" | "message" | "reaction"; + entityId: string | null; + entityProviderId: string; + deletedAt: string; + scope: "remote" | "local" | "unknown"; + providerRevision: string | null; +}>; + +type BundleRecord = AccountRecord | ParticipantRecord | ConversationRecord | MessageRecord | ReactionRecord | TombstoneRecord; + +type Artifact = Readonly<{ + path: string; + mediaType: "application/x-ndjson"; + recordKind: RecordKind; + records: number; + bytes: number; + sha256: string; +}>; + +type Manifest = Readonly<{ + schemaVersion: 1; + format: "message-like-me.local-message-bundle"; + source: Readonly<{ id: "beeper-local"; version: string }>; + provider: Readonly<{ id: "beeper"; version: string }>; + timestamps: Readonly<{ startedAt: string; finishedAt: string; createdAt: string }>; + completeness: Readonly<{ + kind: "bounded-local" | "truncated" | "unknown"; + reason: string | null; + observedFrom: string | null; + observedThrough: string | null; + }>; + warnings: readonly string[]; + privacy: Readonly<{ + classification: "private-local"; + attachments: "metadata-only"; + providerUrls: "excluded"; + credentials: "excluded"; + }>; + counts: Readonly>; + artifacts: readonly Artifact[]; + integrity: Readonly<{ algorithm: "sha256"; bundleSha256: string }>; +}>; + +function object(value: unknown, label: string): JsonObject { + if ( + value === null + || typeof value !== "object" + || Array.isArray(value) + || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) + ) throw new CliError("invalid-data", `${label} must be a plain object`); + return value as JsonObject; +} + +function exactKeys(value: JsonObject, keys: readonly string[], label: string): void { + const expected = [...keys].sort(); + const observed = Object.keys(value).sort(); + if ( + expected.length !== observed.length + || observed.some((key, index) => key !== expected[index]) + ) throw new CliError("invalid-data", `${label} must contain exactly: ${keys.join(", ")}`); +} + +function boundedText(value: unknown, label: string, maximum: number): string { + if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > maximum || value.includes("\u0000")) { + throw new CliError("invalid-data", `${label} must be NUL-free text within ${maximum} UTF-8 bytes`); + } + return value; +} + +function nullableText(value: unknown, label: string, maximum: number): string | null { + return value === null ? null : boundedText(value, label, maximum); +} + +function identifier(value: unknown, label: string): string { + const result = boundedText(value, label, MAX_IDENTIFIER_BYTES); + if (result.length === 0 || /[\u0000-\u001f\u007f]/u.test(result)) { + throw new CliError("invalid-data", `${label} must be a non-empty identifier without ASCII controls`); + } + return result; +} + +function token(value: unknown, label: string, maximum = 128): string { + const result = boundedText(value, label, maximum); + if (!/^[a-z0-9](?:[a-z0-9._+-]*[a-z0-9])?$/u.test(result)) { + throw new CliError("invalid-data", `${label} must be a lowercase categorical token`); + } + return result; +} + +function version(value: unknown, label: string): string { + const result = boundedText(value, label, 128); + if (!/^[A-Za-z0-9](?:[A-Za-z0-9._+-]*[A-Za-z0-9])?$/u.test(result)) { + throw new CliError("invalid-data", `${label} must be a bounded version token`); + } + return result; +} + +function oneOf(value: unknown, values: T, label: string): T[number] { + if (typeof value !== "string" || !values.includes(value)) { + throw new CliError("invalid-data", `${label} must be one of: ${values.join(", ")}`); + } + return value as T[number]; +} + +function integer(value: unknown, label: string, maximum = Number.MAX_SAFE_INTEGER): number { + if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) > maximum) { + throw new CliError("invalid-data", `${label} must be a non-negative safe integer`); + } + return value as number; +} + +function nullableInteger(value: unknown, label: string): number | null { + return value === null ? null : integer(value, label); +} + +function boolean(value: unknown, label: string): boolean { + if (typeof value !== "boolean") throw new CliError("invalid-data", `${label} must be boolean`); + return value; +} + +function nullableBoolean(value: unknown, label: string): boolean | null { + return value === null ? null : boolean(value, label); +} + +function timestamp(value: unknown, label: string): string { + const result = boundedText(value, label, 64); + const date = new Date(result); + if (!Number.isFinite(date.getTime()) || date.toISOString() !== result) { + throw new CliError("invalid-data", `${label} must be a canonical UTC timestamp`); + } + return result; +} + +function nullableTimestamp(value: unknown, label: string): string | null { + return value === null ? null : timestamp(value, label); +} + +function digest(value: unknown, label: string): string { + const result = boundedText(value, label, 64); + if (!/^[a-f0-9]{64}$/u.test(result)) throw new CliError("invalid-data", `${label} must be lowercase SHA-256`); + return result; +} + +function array(value: unknown, label: string, maximum: number): readonly unknown[] { + if (!Array.isArray(value) || value.length > maximum) { + throw new CliError("invalid-data", `${label} must contain at most ${maximum} items`); + } + return value; +} + +function identifiers(value: unknown, label: string, maximum: number): readonly string[] { + const result = array(value, label, maximum).map((item, index) => identifier(item, `${label}[${index}]`)); + if (new Set(result).size !== result.length) throw new CliError("invalid-data", `${label} repeats an ID`); + return Object.freeze(result); +} + +function parseProvenance(value: unknown, label: string): Provenance { + const record = object(value, label); + exactKeys(record, ["providerId", "providerRevision", "observedAt", "connectedAccountProviderId"], label); + return Object.freeze({ + providerId: identifier(record.providerId, `${label}.providerId`), + providerRevision: nullableText(record.providerRevision, `${label}.providerRevision`, MAX_IDENTIFIER_BYTES), + observedAt: timestamp(record.observedAt, `${label}.observedAt`), + connectedAccountProviderId: identifier( + record.connectedAccountProviderId, + `${label}.connectedAccountProviderId`, + ), + }); +} + +function parseCommon( + record: JsonObject, + kind: RecordKind, + extraKeys: readonly string[], + label: string, +): Omit & Readonly<{ kind: RecordKind }> { + exactKeys(record, ["schemaVersion", "kind", "id", "accountId", "network", "provenance", ...extraKeys], label); + if (record.schemaVersion !== 1 || record.kind !== kind) { + throw new CliError("invalid-data", `${label} has the wrong schemaVersion or kind`); + } + return Object.freeze({ + schemaVersion: 1, + kind, + id: identifier(record.id, `${label}.id`), + accountId: identifier(record.accountId, `${label}.accountId`), + network: token(record.network, `${label}.network`, 64), + provenance: parseProvenance(record.provenance, `${label}.provenance`), + }); +} + +function parseAccount(record: JsonObject, label: string): AccountRecord { + const common = parseCommon(record, "account", ["displayName", "handle", "selfParticipantId"], label); + if (common.id !== common.accountId || common.provenance.providerId !== common.provenance.connectedAccountProviderId) { + throw new CliError("invalid-data", `${label} does not establish one connected account realm`); + } + return Object.freeze({ + ...common, + kind: "account", + displayName: nullableText(record.displayName, `${label}.displayName`, MAX_SHORT_TEXT_BYTES), + handle: nullableText(record.handle, `${label}.handle`, MAX_SHORT_TEXT_BYTES), + selfParticipantId: identifier(record.selfParticipantId, `${label}.selfParticipantId`), + }); +} + +function parseParticipant(record: JsonObject, label: string): ParticipantRecord { + const common = parseCommon(record, "participant", ["displayName", "handle", "isSelf"], label); + return Object.freeze({ + ...common, + kind: "participant", + displayName: nullableText(record.displayName, `${label}.displayName`, MAX_SHORT_TEXT_BYTES), + handle: nullableText(record.handle, `${label}.handle`, MAX_SHORT_TEXT_BYTES), + isSelf: boolean(record.isSelf, `${label}.isSelf`), + }); +} + +function parseConversation(record: JsonObject, label: string): ConversationRecord { + const common = parseCommon(record, "conversation", [ + "type", "title", "participantIds", "participantsComplete", "startedAt", "lastMessageAt", + ], label); + const startedAt = nullableTimestamp(record.startedAt, `${label}.startedAt`); + const lastMessageAt = nullableTimestamp(record.lastMessageAt, `${label}.lastMessageAt`); + if (startedAt !== null && lastMessageAt !== null && startedAt > lastMessageAt) { + throw new CliError("invalid-data", `${label}.startedAt must not follow lastMessageAt`); + } + return Object.freeze({ + ...common, + kind: "conversation", + type: oneOf(record.type, ["direct", "group", "channel", "unknown"] as const, `${label}.type`), + title: nullableText(record.title, `${label}.title`, MAX_SHORT_TEXT_BYTES), + participantIds: identifiers(record.participantIds, `${label}.participantIds`, MAX_PARTICIPANTS), + participantsComplete: nullableBoolean(record.participantsComplete, `${label}.participantsComplete`), + startedAt, + lastMessageAt, + }); +} + +function parseReply(value: unknown, label: string): MessageRecord["replyTo"] { + if (value === null) return null; + const record = object(value, label); + exactKeys(record, ["messageId", "providerId"], label); + return Object.freeze({ + messageId: record.messageId === null ? null : identifier(record.messageId, `${label}.messageId`), + providerId: identifier(record.providerId, `${label}.providerId`), + }); +} + +function parseEdit(value: unknown, sentAt: string, label: string): MessageRecord["edit"] { + if (value === null) return null; + const record = object(value, label); + if (record.kind === "in-place") { + exactKeys(record, ["kind", "editedAt", "providerRevision"], label); + const editedAt = timestamp(record.editedAt, `${label}.editedAt`); + if (editedAt < sentAt) throw new CliError("invalid-data", `${label} precedes the message`); + return Object.freeze({ + kind: "in-place", + editedAt, + providerRevision: identifier(record.providerRevision, `${label}.providerRevision`), + }); + } + if (record.kind !== "replacement") { + throw new CliError("invalid-data", `${label}.kind must be in-place or replacement`); + } + exactKeys(record, [ + "kind", "replacesMessageId", "replacesProviderId", "editedAt", "providerRevision", + ], label); + const replacesMessageId = record.replacesMessageId === null + ? null + : identifier(record.replacesMessageId, `${label}.replacesMessageId`); + const replacesProviderId = identifier(record.replacesProviderId, `${label}.replacesProviderId`); + const editedAt = timestamp(record.editedAt, `${label}.editedAt`); + if (editedAt < sentAt) throw new CliError("invalid-data", `${label} precedes the message`); + return Object.freeze({ + kind: "replacement", + replacesMessageId, + replacesProviderId, + editedAt, + providerRevision: identifier(record.providerRevision, `${label}.providerRevision`), + }); +} + +function parseDeletion(value: unknown, label: string): MessageRecord["deletion"] { + if (value === null) return null; + const record = object(value, label); + exactKeys(record, ["state", "observedAt", "providerRevision"], label); + return Object.freeze({ + state: oneOf(record.state, ["revoked", "deleted-for-me", "revoked-and-deleted-for-me"] as const, `${label}.state`), + observedAt: timestamp(record.observedAt, `${label}.observedAt`), + providerRevision: nullableText(record.providerRevision, `${label}.providerRevision`, MAX_IDENTIFIER_BYTES), + }); +} + +function parseAttachments(value: unknown, label: string): readonly AttachmentRecord[] { + return Object.freeze(array(value, label, MAX_ATTACHMENTS).map((item, index) => { + const itemLabel = `${label}[${index}]`; + const record = object(item, itemLabel); + exactKeys(record, ["kind", "mimeType", "name", "sizeBytes"], itemLabel); + const name = nullableText(record.name, `${itemLabel}.name`, MAX_SHORT_TEXT_BYTES); + if (name !== null && (name === "." || name === ".." || name.includes("/") || name.includes("\\"))) { + throw new CliError("invalid-data", `${itemLabel}.name must not be a path`); + } + return Object.freeze({ + kind: oneOf(record.kind, ["audio", "document", "image", "link", "sticker", "video", "unknown"] as const, `${itemLabel}.kind`), + mimeType: nullableText(record.mimeType, `${itemLabel}.mimeType`, 256), + name, + sizeBytes: nullableInteger(record.sizeBytes, `${itemLabel}.sizeBytes`), + }); + })); +} + +function parseMessage(record: JsonObject, label: string): MessageRecord { + const common = parseCommon(record, "message", [ + "conversationId", "senderParticipantId", "direction", "sentAt", "sortKey", "body", + "bodyTruncated", "replyTo", "edit", "deletion", "attachments", + ], label); + const sentAt = timestamp(record.sentAt, `${label}.sentAt`); + const deletion = parseDeletion(record.deletion, `${label}.deletion`); + const body = nullableText(record.body, `${label}.body`, MAX_BODY_BYTES); + if (deletion !== null && body !== null) { + throw new CliError("invalid-data", `${label}.body must be null for a deleted message`); + } + return Object.freeze({ + ...common, + kind: "message", + conversationId: identifier(record.conversationId, `${label}.conversationId`), + senderParticipantId: record.senderParticipantId === null + ? null + : identifier(record.senderParticipantId, `${label}.senderParticipantId`), + direction: oneOf(record.direction, ["incoming", "outgoing", "unknown"] as const, `${label}.direction`), + sentAt, + sortKey: identifier(record.sortKey, `${label}.sortKey`), + body, + bodyTruncated: nullableBoolean(record.bodyTruncated, `${label}.bodyTruncated`), + replyTo: parseReply(record.replyTo, `${label}.replyTo`), + edit: parseEdit(record.edit, sentAt, `${label}.edit`), + deletion, + attachments: parseAttachments(record.attachments, `${label}.attachments`), + }); +} + +function parseReaction(record: JsonObject, label: string): ReactionRecord { + const common = parseCommon(record, "reaction", [ + "messageId", "messageProviderId", "participantId", "body", "reactedAt", "state", + ], label); + return Object.freeze({ + ...common, + kind: "reaction", + messageId: record.messageId === null ? null : identifier(record.messageId, `${label}.messageId`), + messageProviderId: identifier(record.messageProviderId, `${label}.messageProviderId`), + participantId: record.participantId === null + ? null + : identifier(record.participantId, `${label}.participantId`), + body: boundedText(record.body, `${label}.body`, MAX_SHORT_TEXT_BYTES), + reactedAt: nullableTimestamp(record.reactedAt, `${label}.reactedAt`), + state: oneOf(record.state, ["active", "removed"] as const, `${label}.state`), + }); +} + +function parseTombstone(record: JsonObject, label: string): TombstoneRecord { + const common = parseCommon(record, "tombstone", [ + "entityKind", "entityId", "entityProviderId", "deletedAt", "scope", "providerRevision", + ], label); + return Object.freeze({ + ...common, + kind: "tombstone", + entityKind: oneOf(record.entityKind, ["conversation", "message", "reaction"] as const, `${label}.entityKind`), + entityId: record.entityId === null ? null : identifier(record.entityId, `${label}.entityId`), + entityProviderId: identifier(record.entityProviderId, `${label}.entityProviderId`), + deletedAt: timestamp(record.deletedAt, `${label}.deletedAt`), + scope: oneOf(record.scope, ["remote", "local", "unknown"] as const, `${label}.scope`), + providerRevision: nullableText(record.providerRevision, `${label}.providerRevision`, MAX_IDENTIFIER_BYTES), + }); +} + +function parseRecord(value: unknown, kind: RecordKind, label: string): BundleRecord { + const record = object(value, label); + switch (kind) { + case "account": return parseAccount(record, label); + case "participant": return parseParticipant(record, label); + case "conversation": return parseConversation(record, label); + case "message": return parseMessage(record, label); + case "reaction": return parseReaction(record, label); + case "tombstone": return parseTombstone(record, label); + } +} + +function parseArtifact(value: unknown, index: number): Artifact { + const expected = ARTIFACTS[index]!; + const label = `manifest.artifacts[${index}]`; + const record = object(value, label); + exactKeys(record, ["path", "mediaType", "recordKind", "records", "bytes", "sha256"], label); + if ( + record.path !== expected.path + || record.mediaType !== "application/x-ndjson" + || record.recordKind !== expected.kind + ) throw new CliError("invalid-data", `${label} does not match the fixed artifact inventory`); + return Object.freeze({ + path: expected.path, + mediaType: "application/x-ndjson", + recordKind: expected.kind, + records: integer(record.records, `${label}.records`, MAX_RECORDS), + bytes: integer(record.bytes, `${label}.bytes`, MAX_TOTAL_BYTES), + sha256: digest(record.sha256, `${label}.sha256`), + }); +} + +function parseManifest(value: unknown): Manifest { + const record = object(value, "manifest"); + exactKeys(record, [ + "schemaVersion", "format", "source", "provider", "timestamps", "completeness", + "warnings", "privacy", "counts", "artifacts", "integrity", + ], "manifest"); + if (record.schemaVersion !== MESSAGE_BUNDLE_SCHEMA_VERSION || record.format !== "message-like-me.local-message-bundle") { + throw new CliError("invalid-data", "Manifest has an unsupported schemaVersion or format"); + } + const source = object(record.source, "manifest.source"); + exactKeys(source, ["id", "version"], "manifest.source"); + if (source.id !== "beeper-local") throw new CliError("invalid-data", "manifest.source.id must be beeper-local"); + const provider = object(record.provider, "manifest.provider"); + exactKeys(provider, ["id", "version"], "manifest.provider"); + if (provider.id !== "beeper") throw new CliError("invalid-data", "manifest.provider.id must be beeper"); + const timestamps = object(record.timestamps, "manifest.timestamps"); + exactKeys(timestamps, ["startedAt", "finishedAt", "createdAt"], "manifest.timestamps"); + const startedAt = timestamp(timestamps.startedAt, "manifest.timestamps.startedAt"); + const finishedAt = timestamp(timestamps.finishedAt, "manifest.timestamps.finishedAt"); + const createdAt = timestamp(timestamps.createdAt, "manifest.timestamps.createdAt"); + if (startedAt > finishedAt || finishedAt > createdAt) { + throw new CliError("invalid-data", "Manifest timestamps are not monotonic"); + } + const completeness = object(record.completeness, "manifest.completeness"); + exactKeys(completeness, ["kind", "reason", "observedFrom", "observedThrough"], "manifest.completeness"); + const observedFrom = nullableTimestamp(completeness.observedFrom, "manifest.completeness.observedFrom"); + const observedThrough = nullableTimestamp(completeness.observedThrough, "manifest.completeness.observedThrough"); + if (observedFrom !== null && observedThrough !== null && observedFrom > observedThrough) { + throw new CliError("invalid-data", "Manifest completeness bounds are reversed"); + } + const warnings = array(record.warnings, "manifest.warnings", MAX_WARNINGS) + .map((value, index) => token(value, `manifest.warnings[${index}]`)); + if (new Set(warnings).size !== warnings.length) throw new CliError("invalid-data", "Manifest warnings repeat"); + const privacy = object(record.privacy, "manifest.privacy"); + exactKeys(privacy, ["classification", "attachments", "providerUrls", "credentials"], "manifest.privacy"); + if ( + privacy.classification !== "private-local" + || privacy.attachments !== "metadata-only" + || privacy.providerUrls !== "excluded" + || privacy.credentials !== "excluded" + ) throw new CliError("invalid-data", "Manifest privacy guarantees are unsupported"); + const counts = object(record.counts, "manifest.counts"); + exactKeys(counts, ARTIFACTS.map(({ kind }) => kind), "manifest.counts"); + const parsedCounts = Object.fromEntries(ARTIFACTS.map(({ kind }) => [ + kind, + integer(counts[kind], `manifest.counts.${kind}`, MAX_RECORDS), + ])) as Record; + if (parsedCounts.account > MAX_ACCOUNTS) { + throw new CliError("invalid-data", `Manifest exceeds the ${MAX_ACCOUNTS}-account safety bound`); + } + if (!Array.isArray(record.artifacts) || record.artifacts.length !== ARTIFACTS.length) { + throw new CliError("invalid-data", "Manifest must list the fixed six artifacts"); + } + const artifacts = Object.freeze(record.artifacts.map(parseArtifact)); + let totalRecords = 0; + let totalBytes = 0; + for (const artifact of artifacts) { + if (artifact.records !== parsedCounts[artifact.recordKind]) { + throw new CliError("invalid-data", `${artifact.path} count disagrees with manifest.counts`); + } + totalRecords += artifact.records; + totalBytes += artifact.bytes; + } + if (totalRecords > MAX_RECORDS || totalBytes > MAX_TOTAL_BYTES) { + throw new CliError("invalid-data", "Manifest exceeds the bundle record or byte bound"); + } + const integrity = object(record.integrity, "manifest.integrity"); + exactKeys(integrity, ["algorithm", "bundleSha256"], "manifest.integrity"); + if (integrity.algorithm !== "sha256") throw new CliError("invalid-data", "Manifest integrity algorithm is unsupported"); + const result: Manifest = Object.freeze({ + schemaVersion: 1, + format: "message-like-me.local-message-bundle", + source: Object.freeze({ id: "beeper-local", version: version(source.version, "manifest.source.version") }), + provider: Object.freeze({ id: "beeper", version: version(provider.version, "manifest.provider.version") }), + timestamps: Object.freeze({ startedAt, finishedAt, createdAt }), + completeness: Object.freeze({ + kind: oneOf(completeness.kind, ["bounded-local", "truncated", "unknown"] as const, "manifest.completeness.kind"), + reason: completeness.reason === null ? null : token(completeness.reason, "manifest.completeness.reason"), + observedFrom, + observedThrough, + }), + warnings: Object.freeze(warnings), + privacy: Object.freeze({ + classification: "private-local", + attachments: "metadata-only", + providerUrls: "excluded", + credentials: "excluded", + }), + counts: Object.freeze(parsedCounts), + artifacts, + integrity: Object.freeze({ algorithm: "sha256", bundleSha256: digest(integrity.bundleSha256, "manifest.integrity.bundleSha256") }), + }); + const { integrity: _integrity, ...projection } = result; + if (sha256(canonicalJson(projection)) !== result.integrity.bundleSha256) { + throw new CliError("invalid-data", "Manifest bundle SHA-256 does not match its canonical projection"); + } + return result; +} + +function sameFile(left: Awaited>, right: Awaited>): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +async function bundleDirectory(path: string): Promise { + if (!isAbsolute(path) || resolve(path) !== path) { + throw new CliError("unsafe-path", "Bundle input must be a normalized absolute path"); + } + const before = await lstat(path); + if ( + !before.isDirectory() + || before.isSymbolicLink() + || (before.mode & 0o777) !== 0o700 + || (typeof process.getuid === "function" && before.uid !== process.getuid()) + ) throw new CliError("unsafe-path", "Bundle input must be a current-user-owned mode-0700 physical directory"); + const physical = await realpath(path); + if (physical !== path) throw new CliError("unsafe-path", "Bundle input path must not traverse a symbolic link"); + const after = await lstat(physical); + if (!sameFile(before, after)) throw new CliError("unsafe-path", "Bundle directory changed while resolving"); + const expected = ["manifest.json", ...ARTIFACTS.map(({ path: artifactPath }) => artifactPath)].sort(); + const entries = (await readdir(physical)).sort(); + if (entries.length !== expected.length || entries.some((entry, index) => entry !== expected[index])) { + throw new CliError("invalid-data", "Bundle directory does not contain exactly the version-one inventory"); + } + return physical; +} + +async function openPrivateFile(path: string, maximumBytes: number, allowEmpty: boolean): Promise<{ + handle: FileHandle; + before: BigIntStats; +}> { + const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + try { + const before = await handle.stat({ bigint: true }); + if ( + !before.isFile() + || before.nlink !== 1n + || before.size > BigInt(maximumBytes) + || (!allowEmpty && before.size < 1n) + || (before.mode & 0o777n) !== 0o600n + || (typeof process.getuid === "function" && before.uid !== BigInt(process.getuid())) + ) throw new CliError("unsafe-path", `${path} must be a private physical file within its bound`); + return { handle, before }; + } catch (error) { + await handle.close(); + throw error; + } +} + +async function assertFileUnchanged( + path: string, + handle: FileHandle, + before: BigIntStats, +): Promise { + const after = await handle.stat({ bigint: true }); + if ( + before.dev !== after.dev + || before.ino !== after.ino + || before.size !== after.size + || before.mtimeNs !== after.mtimeNs + || before.ctimeNs !== after.ctimeNs + ) throw new CliError("unsafe-path", `${path} changed while it was read`); +} + +async function closeReadHandle(handle: FileHandle): Promise { + try { + await handle.close(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EBADF") throw error; + } +} + +function decodeUtf8(bytes: Uint8Array, label: string): string { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + throw new CliError("invalid-data", `${label} is not valid UTF-8`, { cause: error }); + } +} + +async function readManifest(path: string): Promise> { + const opened = await openPrivateFile(path, MAX_MANIFEST_BYTES, false); + try { + const bytes = Uint8Array.from(await opened.handle.readFile()); + await assertFileUnchanged(path, opened.handle, opened.before); + let value: unknown; + try { + value = JSON.parse(decodeUtf8(bytes, "manifest.json")) as unknown; + } catch (error) { + throw new CliError("invalid-data", "manifest.json is not valid UTF-8 JSON", { cause: error }); + } + const manifest = parseManifest(value); + if (!Buffer.from(`${canonicalJson(manifest)}\n`, "utf8").equals(Buffer.from(bytes))) { + throw new CliError("invalid-data", "manifest.json must use canonical JSON with one final newline"); + } + return Object.freeze({ bytes, manifest }); + } finally { + await closeReadHandle(opened.handle); + } +} + +async function readArtifact(root: string, artifact: Artifact): Promise { + const path = join(root, artifact.path); + const opened = await openPrivateFile(path, artifact.bytes, true); + const hash = createHash("sha256"); + const records: BundleRecord[] = []; + let totalBytes = 0; + let pending: Buffer = Buffer.alloc(0); + let endedWithNewline = false; + try { + const stream = createReadStream(path, { + fd: opened.handle.fd, + autoClose: false, + start: 0, + highWaterMark: 64 * 1024, + }); + for await (const value of stream) { + const chunk: Buffer = Buffer.from(value as Uint8Array); + hash.update(chunk); + totalBytes += chunk.byteLength; + if (totalBytes > artifact.bytes) throw new CliError("invalid-data", `${artifact.path} exceeds manifest bytes`); + pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk]); + let newline = pending.indexOf(0x0a); + while (newline >= 0) { + const line = pending.subarray(0, newline); + pending = pending.subarray(newline + 1); + endedWithNewline = true; + if (line.byteLength < 1 || line.byteLength + 1 > MAX_RECORD_BYTES) { + throw new CliError("invalid-data", `${artifact.path} contains a blank or oversized record`); + } + let parsed: unknown; + try { + parsed = JSON.parse(decodeUtf8(line, `${artifact.path} record`)) as unknown; + } catch (error) { + throw new CliError("invalid-data", `${artifact.path} contains invalid UTF-8 JSON`, { cause: error }); + } + const normalized = parseRecord( + parsed, + artifact.recordKind, + `${artifact.path}:${records.length + 1}`, + ); + if (!Buffer.from(canonicalJson(normalized), "utf8").equals(line)) { + throw new CliError("invalid-data", `${artifact.path} records must use canonical JSON`); + } + records.push(normalized); + if (records.length > artifact.records) { + throw new CliError("invalid-data", `${artifact.path} exceeds its manifest record count`); + } + newline = pending.indexOf(0x0a); + } + if (pending.byteLength + 1 > MAX_RECORD_BYTES) { + throw new CliError("invalid-data", `${artifact.path} contains an oversized record`); + } + if (pending.length > 0) endedWithNewline = false; + } + await assertFileUnchanged(path, opened.handle, opened.before); + } finally { + await closeReadHandle(opened.handle); + } + if (pending.byteLength !== 0 || (artifact.records > 0 && !endedWithNewline)) { + throw new CliError("invalid-data", `${artifact.path} must end every record with a newline`); + } + if ( + totalBytes !== artifact.bytes + || records.length !== artifact.records + || hash.digest("hex") !== artifact.sha256 + ) throw new CliError("invalid-data", `${artifact.path} does not match its manifest integrity`); + return Object.freeze(records); +} + +function hmacKey(value: string | Uint8Array): Uint8Array { + const key = typeof value === "string" ? new TextEncoder().encode(value) : value; + if (!(key instanceof Uint8Array) || key.byteLength < 16 || key.byteLength > 1_024) { + throw new CliError("invalid-data", "Bundle HMAC key must contain 16 through 1024 bytes"); + } + return Uint8Array.from(key); +} + +function hmac(key: Uint8Array, namespace: string, value: string): string { + return createHmac("sha256", key) + .update(`message-like-me\0bundle-${namespace}\0`, "utf8") + .update(value, "utf8") + .digest("hex"); +} + +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function recordMap(records: readonly T[], label: string): Map { + const result = new Map(); + for (const record of records) { + if (result.has(record.id)) throw new CliError("invalid-data", `${label} repeats a bundle-local ID`); + result.set(record.id, record); + } + return result; +} + +function groupByAccount(records: readonly T[]): Map { + const grouped = new Map(); + for (const record of records) { + const values = grouped.get(record.accountId) ?? []; + values.push(record); + grouped.set(record.accountId, values); + } + return grouped; +} + +function attachmentProvenance( + messageId: string, + attachments: readonly AttachmentRecord[], +): readonly CorpusAttachmentProvenance[] { + return Object.freeze(attachments.map((attachment, index) => ({ + id: `${messageId}:attachment:${index + 1}`, + kind: attachment.kind, + mimeType: attachment.mimeType, + fileName: attachment.name, + bytes: attachment.sizeBytes, + }))); +} + +/** + * Timeline reactions share the messages table, but provider message and + * reaction IDs occupy independent foreign domains. The control-prefixed local + * coordinate cannot equal a provider identifier because the bundle parser + * rejects ASCII controls in every provider ID. + */ +function reactionTimelineCoordinate(localReactionId: string): string { + return `\u001freaction-timeline:${localReactionId}`; +} + +function normalizeBundle( + manifest: Manifest, + manifestSha256: string, + records: Readonly>, + key: Uint8Array, +): readonly SourceCorpusSnapshot[] { + const accounts = records.account as readonly AccountRecord[]; + const participants = records.participant as readonly ParticipantRecord[]; + const conversations = records.conversation as readonly ConversationRecord[]; + const messages = records.message as readonly MessageRecord[]; + const reactions = records.reaction as readonly ReactionRecord[]; + const tombstones = records.tombstone as readonly TombstoneRecord[]; + if (accounts.length > MAX_ACCOUNTS) { + throw new CliError("invalid-data", `Bundle exceeds the ${MAX_ACCOUNTS}-account safety bound`); + } + const accountById = recordMap(accounts, "accounts"); + recordMap(participants, "participants"); + recordMap(conversations, "conversations"); + const messageRecordById = recordMap(messages, "messages"); + const reactionById = recordMap(reactions, "reactions"); + recordMap(tombstones, "tombstones"); + for (const [kind, values] of [ + ["account", accounts], + ["participant", participants], + ["conversation", conversations], + ["message", messages], + ["reaction", reactions], + ["tombstone", tombstones], + ] as const) { + const providerCoordinates = new Set(); + for (const record of values) { + const coordinate = `${record.accountId}\0${record.provenance.providerId}`; + if (providerCoordinates.has(coordinate)) { + throw new CliError( + "invalid-data", + `${kind} records repeat a provider identity within one account`, + ); + } + providerCoordinates.add(coordinate); + } + } + for (const record of [...participants, ...conversations, ...messages, ...reactions, ...tombstones]) { + const account = accountById.get(record.accountId); + if ( + account === undefined + || account.network !== record.network + || account.provenance.connectedAccountProviderId !== record.provenance.connectedAccountProviderId + ) throw new CliError("invalid-data", "A record does not match its connected account realm"); + } + const participantsByAccount = groupByAccount(participants); + const conversationsByAccount = groupByAccount(conversations); + const messagesByAccount = groupByAccount(messages); + const reactionsByAccount = groupByAccount(reactions); + const tombstonesByAccount = groupByAccount(tombstones); + + const result: SourceCorpusSnapshot[] = []; + const sourceIds = new Set(); + for (const account of accounts) { + const accountParticipants = participantsByAccount.get(account.id) ?? []; + const participantById = new Map(accountParticipants.map((participant) => [participant.id, participant])); + const self = participantById.get(account.selfParticipantId); + if ( + self === undefined + || !self.isSelf + || accountParticipants.filter(({ isSelf }) => isSelf).length !== 1 + ) { + throw new CliError("invalid-data", "An account must have exactly one matching self participant"); + } + const accountConversations = conversationsByAccount.get(account.id) ?? []; + const conversationParticipantIds = new Map(accountConversations.map((conversation) => [ + conversation.id, + new Set(conversation.participantIds), + ])); + for (const conversation of accountConversations) { + for (const participantId of conversation.participantIds) { + if (!participantById.has(participantId)) { + throw new CliError( + "invalid-data", + "A conversation references an unknown participant", + ); + } + } + if ( + conversation.type === "direct" + && conversation.participantsComplete === true + && ( + conversation.participantIds.length !== 2 + || !conversation.participantIds.includes(account.selfParticipantId) + || conversation.participantIds.filter((participantId) => + participantById.get(participantId)?.isSelf === false).length !== 1 + ) + ) { + throw new CliError( + "invalid-data", + "A complete direct conversation must contain one self and one non-self participant", + ); + } + } + const conversationById = new Map(accountConversations.map((conversation) => [conversation.id, conversation])); + const namespace = [ + manifest.provider.id, + account.provenance.connectedAccountProviderId, + self.provenance.providerId, + ].join("\0"); + const sourceId = `source_${hmac(key, "source", namespace)}`; + if (sourceIds.has(sourceId)) { + throw new CliError( + "invalid-data", + "Connected accounts repeat a stable source realm", + ); + } + sourceIds.add(sourceId); + const conversationLocalIds = new Map(accountConversations.map((conversation) => [ + conversation.id, + `conversation_${hmac(key, "conversation", `${namespace}\0${conversation.provenance.providerId}`)}`, + ])); + const participantLocalIds = new Map(accountParticipants.map((participant) => [ + participant.id, + `participant_${hmac(key, "participant", `${namespace}\0${participant.provenance.providerId}`)}`, + ])); + const normalizedConversations: CorpusConversation[] = accountConversations.map((conversation) => { + const known = conversation.participantIds.flatMap((id) => { + const participant = participantById.get(id); + return participant === undefined ? [] : [participant]; + }); + const peers = known.filter(({ isSelf }) => !isSelf); + const completeDirectPeer = conversation.type === "direct" + && conversation.participantsComplete === true + && peers.length === 1 + ? peers[0]! + : null; + const canonicalHandle = completeDirectPeer?.handle === null || completeDirectPeer === null + ? null + : normalizeContactHandle(completeDirectPeer.handle); + return Object.freeze({ + id: conversationLocalIds.get(conversation.id)!, + sourceKey: conversation.provenance.providerId, + privateLabel: conversation.title, + service: account.network, + participantCount: conversation.type === "direct" ? 1 : peers.length, + participantIds: Object.freeze(peers.map((participant) => participantLocalIds.get(participant.id)!)), + privateParticipants: canonicalHandle === null + ? Object.freeze([]) + : Object.freeze([canonicalHandle.normalizedValue]), + group: conversation.type !== "direct", + }); + }); + + const accountMessages = messagesByAccount.get(account.id) ?? []; + const messageById = new Map(accountMessages.map((message) => [message.id, message])); + const messageByProviderId = new Map(accountMessages.map((message) => [ + message.provenance.providerId, + message, + ])); + const replacementTargets = new Map>(); + const replacerByTarget = new Map(); + for (const message of accountMessages) { + if (!conversationById.has(message.conversationId)) { + throw new CliError("invalid-data", "A message references an unknown conversation"); + } + if (message.senderParticipantId !== null && !participantById.has(message.senderParticipantId)) { + throw new CliError("invalid-data", "A message references an unknown sender participant"); + } + const sender = message.senderParticipantId === null + ? null + : participantById.get(message.senderParticipantId)!; + const conversation = conversationById.get(message.conversationId)!; + if ( + sender !== null + && ( + (message.direction === "outgoing" && !sender.isSelf) + || (message.direction === "incoming" && sender.isSelf) + ) + ) throw new CliError("invalid-data", "A message direction conflicts with its sender identity"); + if ( + sender !== null + && conversation.participantsComplete === true + && !conversationParticipantIds.get(conversation.id)!.has(sender.id) + ) throw new CliError("invalid-data", "A message sender is outside its complete conversation roster"); + if (message.replyTo !== null) { + const localTarget = message.replyTo.messageId === null + ? undefined + : messageRecordById.get(message.replyTo.messageId); + if ( + message.replyTo.messageId !== null + && ( + localTarget === undefined + || localTarget.accountId !== account.id + || localTarget.provenance.providerId !== message.replyTo.providerId + ) + ) throw new CliError("invalid-data", "A message reply has mismatched target coordinates"); + const providerTarget = messageByProviderId.get(message.replyTo.providerId); + const target = localTarget ?? providerTarget; + if ( + message.replyTo.providerId === message.provenance.providerId + || (target !== undefined && target.conversationId !== message.conversationId) + ) throw new CliError("invalid-data", "A message reply has an invalid conversation target"); + } + if (message.edit?.kind === "replacement") { + const localTarget = message.edit.replacesMessageId === null + ? undefined + : messageRecordById.get(message.edit.replacesMessageId); + if ( + message.edit.replacesMessageId !== null + && ( + localTarget === undefined + || localTarget.accountId !== account.id + || localTarget.provenance.providerId !== message.edit.replacesProviderId + ) + ) throw new CliError("invalid-data", "A message edit has mismatched replacement coordinates"); + const providerTarget = messageByProviderId.get(message.edit.replacesProviderId); + const target = localTarget ?? providerTarget; + if ( + message.edit.replacesProviderId === message.provenance.providerId + || (target !== undefined && target.conversationId !== message.conversationId) + ) throw new CliError("invalid-data", "A message edit has an invalid replacement target"); + if (replacerByTarget.has(message.edit.replacesProviderId)) { + throw new CliError("invalid-data", "A message version has multiple replacements"); + } + replacerByTarget.set(message.edit.replacesProviderId, message.provenance.providerId); + replacementTargets.set(message.id, Object.freeze({ + target, + externalId: message.edit.replacesProviderId, + })); + } + } + const editEdges = new Map([...replacementTargets.entries()].map(([messageId, target]) => [ + messageById.get(messageId)!.provenance.providerId, + target.externalId, + ])); + const completedEditNodes = new Set(); + for (const start of editEdges.keys()) { + if (completedEditNodes.has(start)) continue; + const seen = new Set(); + const chain: string[] = []; + let current: string | undefined = start; + while (current !== undefined && !completedEditNodes.has(current)) { + if (seen.has(current)) throw new CliError("invalid-data", "Message replacement edits contain a cycle"); + seen.add(current); + chain.push(current); + current = editEdges.get(current); + } + for (const node of chain) completedEditNodes.add(node); + } + const analyzableMessages = accountMessages.filter(({ direction }) => direction !== "unknown") + .sort((left, right) => + compareCodeUnits(left.conversationId, right.conversationId) + || compareCodeUnits(left.sortKey, right.sortKey) + || compareCodeUnits(left.sentAt, right.sentAt) + || compareCodeUnits(left.id, right.id)); + const normalizedMessages: CorpusMessage[] = []; + const messageProvenance: CorpusMessageProvenance[] = []; + const localMessageIds = new Map(); + const localReactionIds = new Map(); + const timelineReactionIds = new Set(); + for (const [index, message] of analyzableMessages.entries()) { + const localId = `message_${hmac(key, "message", `${namespace}\0${message.provenance.providerId}`)}`; + localMessageIds.set(message.id, localId); + const body = message.bodyTruncated === true || message.deletion !== null ? null : message.body; + normalizedMessages.push(Object.freeze({ + id: localId, + sourceRowId: index + 1, + sourceGuid: message.provenance.providerId, + conversationId: conversationLocalIds.get(message.conversationId)!, + sentAt: message.sentAt, + direction: message.direction as "incoming" | "outgoing", + body, + bodySource: body === null ? "unavailable" : "text", + kind: body !== null || message.bodyTruncated === true + ? "text" + : message.attachments.length > 0 ? "attachment" : "unknown", + replyToSourceGuid: message.replyTo?.providerId ?? null, + editedAt: message.edit?.editedAt ?? null, + retractedAt: message.deletion?.observedAt ?? null, + service: account.network, + attachmentCount: message.attachments.length, + })); + messageProvenance.push(Object.freeze({ + messageId: localId, + externalId: message.provenance.providerId, + replyToExternalId: message.replyTo?.providerId ?? null, + attachments: attachmentProvenance(localId, message.attachments), + metadata: message, + })); + } + + const accountReactions = reactionsByAccount.get(account.id) ?? []; + for (const reaction of accountReactions) { + localReactionIds.set( + reaction.id, + `message_${hmac(key, "reaction", `${namespace}\0${reaction.provenance.providerId}`)}`, + ); + } + const reactionFacts: CorpusReactionFact[] = []; + for (const reaction of accountReactions) { + const localTarget = reaction.messageId === null + ? undefined + : messageRecordById.get(reaction.messageId); + if ( + reaction.messageId !== null + && ( + localTarget === undefined + || localTarget.accountId !== account.id + || localTarget.provenance.providerId !== reaction.messageProviderId + ) + ) throw new CliError("invalid-data", "A reaction has mismatched target coordinates"); + if (reaction.participantId !== null && !participantById.has(reaction.participantId)) { + throw new CliError("invalid-data", "A reaction references an unknown participant"); + } + const target = localTarget ?? messageByProviderId.get(reaction.messageProviderId); + const participant = reaction.participantId === null + ? null + : participantById.get(reaction.participantId)!; + const targetConversationId = target === undefined + ? null + : conversationLocalIds.get(target.conversationId) ?? null; + if (target !== undefined && participant !== null) { + const targetConversation = conversationById.get(target.conversationId)!; + if ( + targetConversation.participantsComplete === true + && !conversationParticipantIds.get(targetConversation.id)!.has(participant.id) + ) throw new CliError("invalid-data", "A reaction participant is outside its complete conversation roster"); + } + const localId = localReactionIds.get(reaction.id)!; + reactionFacts.push(Object.freeze({ + id: localId, + externalId: reaction.provenance.providerId, + targetExternalId: reaction.messageProviderId, + conversationId: targetConversationId, + direction: participant === null ? null : participant.isSelf ? "outgoing" : "incoming", + body: reaction.body, + reactedAt: reaction.reactedAt, + state: reaction.state, + })); + if (reaction.state !== "active" || reaction.reactedAt === null || reaction.participantId === null) continue; + if (participant === null || target === undefined || targetConversationId === null) continue; + timelineReactionIds.add(reaction.id); + const timelineCoordinate = reactionTimelineCoordinate(localId); + normalizedMessages.push(Object.freeze({ + id: localId, + sourceRowId: normalizedMessages.length + 1, + sourceGuid: timelineCoordinate, + conversationId: targetConversationId, + sentAt: reaction.reactedAt, + direction: participant.isSelf ? "outgoing" : "incoming", + body: null, + bodySource: "unavailable", + kind: "reaction", + replyToSourceGuid: reaction.messageProviderId, + editedAt: null, + retractedAt: null, + service: account.network, + attachmentCount: 0, + })); + messageProvenance.push(Object.freeze({ + messageId: localId, + externalId: timelineCoordinate, + replyToExternalId: reaction.messageProviderId, + attachments: Object.freeze([]), + metadata: reaction, + })); + } + const reactionFactByExternal = new Map(reactionFacts.map((fact) => [fact.externalId, fact])); + + const auxiliaryRecords: CorpusSourceRecord[] = [ + { kind: "account", id: account.provenance.providerId, record: account }, + ...accountParticipants.map((participant) => ({ + kind: "participant" as const, + id: participant.provenance.providerId, + record: participant, + })), + ...accountReactions.map((reaction) => ({ + kind: "reaction" as const, + id: reaction.provenance.providerId, + record: reaction, + })), + ...(tombstonesByAccount.get(account.id) ?? []).map((tombstone) => ({ + kind: "tombstone" as const, + id: tombstone.provenance.providerId, + record: tombstone, + })), + ...accountMessages.filter(({ direction }) => direction === "unknown").map((message) => ({ + kind: "excluded-message" as const, + id: message.provenance.providerId, + record: message, + })), + ]; + const accountTombstones = tombstonesByAccount.get(account.id) ?? []; + const deletions: CorpusSourceDeletion[] = accountTombstones.map((tombstone) => { + const entityId = tombstone.entityId; + let localEntityId: string | null = null; + if (entityId !== null) { + if (tombstone.entityKind === "conversation") { + const target = conversationById.get(entityId); + if (target === undefined) { + throw new CliError("invalid-data", "A tombstone references an unknown local conversation"); + } + if (target.provenance.providerId !== tombstone.entityProviderId) { + throw new CliError("invalid-data", "A tombstone has mismatched conversation identity"); + } + localEntityId = conversationLocalIds.get(entityId) ?? null; + } else if (tombstone.entityKind === "message") { + const target = messageRecordById.get(entityId); + if (target === undefined || target.accountId !== account.id) { + throw new CliError("invalid-data", "A tombstone references an unknown local message"); + } + if (target.provenance.providerId !== tombstone.entityProviderId) { + throw new CliError("invalid-data", "A tombstone has mismatched message identity"); + } + localEntityId = localMessageIds.get(entityId) ?? null; + } else if (tombstone.entityKind === "reaction") { + const target = reactionById.get(entityId); + if (target === undefined || target.accountId !== account.id) { + throw new CliError("invalid-data", "A tombstone references an unknown local reaction"); + } + if (target.provenance.providerId !== tombstone.entityProviderId) { + throw new CliError("invalid-data", "A tombstone has mismatched reaction identity"); + } + localEntityId = localReactionIds.get(entityId) ?? null; + } + } + return Object.freeze({ + entityKind: tombstone.entityKind, + localEntityId, + externalId: tombstone.entityProviderId, + deletedAt: tombstone.deletedAt, + reason: "tombstone" as const, + }); + }); + for (const [messageId, replacement] of replacementTargets) { + const message = messageById.get(messageId)!; + deletions.push(Object.freeze({ + entityKind: "message" as const, + localEntityId: replacement.target === undefined + ? null + : localMessageIds.get(replacement.target.id) ?? null, + externalId: replacement.externalId, + deletedAt: message.edit!.editedAt, + expectedConversationId: conversationLocalIds.get(message.conversationId)!, + reason: "replacement" as const, + })); + } + for (const message of accountMessages) { + if (message.deletion === null) continue; + deletions.push(Object.freeze({ + entityKind: "message" as const, + localEntityId: localMessageIds.get(message.id) ?? null, + externalId: message.provenance.providerId, + deletedAt: message.deletion.observedAt, + expectedConversationId: conversationLocalIds.get(message.conversationId)!, + reason: "tombstone" as const, + })); + } + for (const message of accountMessages) { + if (message.direction !== "unknown") continue; + deletions.push(Object.freeze({ + entityKind: "message" as const, + localEntityId: null, + externalId: message.provenance.providerId, + deletedAt: message.provenance.observedAt, + expectedConversationId: conversationLocalIds.get(message.conversationId)!, + reason: "explicit-exclusion" as const, + })); + } + for (const reaction of accountReactions) { + if (timelineReactionIds.has(reaction.id)) continue; + const fact = reactionFactByExternal.get(reaction.provenance.providerId)!; + deletions.push(Object.freeze({ + entityKind: reaction.state === "removed" ? "reaction" as const : "reaction-timeline" as const, + localEntityId: localReactionIds.get(reaction.id)!, + externalId: reaction.provenance.providerId, + deletedAt: reaction.provenance.observedAt, + ...(fact.conversationId === null ? {} : { expectedConversationId: fact.conversationId }), + reason: reaction.state === "removed" ? "tombstone" as const : "explicit-exclusion" as const, + })); + } + const sourceWarnings = [...manifest.warnings]; + const unknownDirections = accountMessages.filter(({ direction }) => direction === "unknown").length; + const undatedReactions = accountReactions.filter(({ reactedAt }) => reactedAt === null).length; + if (unknownDirections > 0) sourceWarnings.push(`unknown-direction-messages:${unknownDirections}`); + if (undatedReactions > 0) sourceWarnings.push(`undated-reactions:${undatedReactions}`); + const accountTimelineBounds = [ + ...accountMessages.map(({ sentAt }) => sentAt), + ...accountReactions.flatMap(({ reactedAt }) => reactedAt === null ? [] : [reactedAt]), + ].sort(compareCodeUnits); + const accountObservedFrom = accountTimelineBounds[0] ?? null; + const accountObservedThrough = accountTimelineBounds.at(-1) ?? null; + const revisionHash = createHash("sha256"); + const revisionHeader = canonicalJson({ + schemaVersion: 1, + source: manifest.source, + provider: manifest.provider, + completeness: manifest.completeness, + warnings: manifest.warnings, + }); + revisionHash.update(`${revisionHeader.length}:`, "utf8").update(revisionHeader, "utf8"); + for (const [kind, values] of [ + ["account", [account]], + ["participant", accountParticipants], + ["conversation", accountConversations], + ["message", accountMessages], + ["reaction", accountReactions], + ["tombstone", accountTombstones], + ] as const) { + revisionHash.update(`${kind.length}:${kind}`, "utf8"); + for (const record of values) { + const encoded = canonicalJson(record); + revisionHash.update(`${Buffer.byteLength(encoded, "utf8")}:`, "utf8").update(encoded, "utf8"); + } + } + const revision = revisionHash.digest("hex"); + result.push(Object.freeze({ + source: Object.freeze({ + id: sourceId, + kind: "bundle", + provider: manifest.provider.id, + network: account.network, + accountId: account.provenance.connectedAccountProviderId, + externalId: account.provenance.connectedAccountProviderId, + revision, + generatedAt: manifest.timestamps.createdAt, + producer: manifest.source, + coverage: Object.freeze({ + history: manifest.completeness.kind === "unknown" ? "unknown" : "bounded", + observedFrom: accountObservedFrom, + observedTo: accountObservedThrough, + kind: manifest.completeness.kind, + reason: manifest.completeness.reason, + }), + manifestSha256, + identity: Object.freeze({ account, selfParticipantProviderId: self.provenance.providerId }), + warnings: Object.freeze(sourceWarnings), + }), + conversations: Object.freeze(normalizedConversations), + conversationProvenance: Object.freeze(accountConversations.map((conversation) => ({ + conversationId: conversationLocalIds.get(conversation.id)!, + externalId: conversation.provenance.providerId, + metadata: conversation, + }))), + messages: Object.freeze(normalizedMessages), + messageProvenance: Object.freeze(messageProvenance), + reactionFacts: Object.freeze(reactionFacts), + auxiliaryRecords: Object.freeze(auxiliaryRecords), + deletions: Object.freeze(deletions), + })); + } + return Object.freeze(result); +} + +/** Read one complete private Wrench/Beeper replacement-snapshot bundle. */ +export async function readMessageBundle( + path: string, + options: Readonly<{ hmacKey: string | Uint8Array }>, +): Promise { + const key = hmacKey(options.hmacKey); + const root = await bundleDirectory(path); + const manifestResult = await readManifest(join(root, "manifest.json")); + const manifest = manifestResult.manifest; + const manifestSha256 = sha256(manifestResult.bytes); + const parsedRecords: Array = []; + for (const artifact of manifest.artifacts) parsedRecords.push(await readArtifact(root, artifact)); + const records = Object.fromEntries(manifest.artifacts.map((artifact, index) => [ + artifact.recordKind, + parsedRecords[index]!, + ])) as Record; + return Object.freeze({ + schemaVersion: MESSAGE_BUNDLE_SCHEMA_VERSION, + manifestSha256, + sources: normalizeBundle(manifest, manifestSha256, records, key), + }); +} diff --git a/src/commands.test.ts b/src/commands.test.ts index 587b61b..f3783ca 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -8,6 +8,7 @@ import type { CommandIo } from "./io.ts"; import { dataPaths, initializeDataPaths, loadOrCreateInstallKey } from "./paths.ts"; import { LocalStore } from "./store.ts"; import { syntheticProfileV2 } from "./test-fixtures.ts"; +import { writeSyntheticMessageBundle } from "./test-bundle-fixture.ts"; import type { CorpusSnapshot, StudyPacket } from "./types.ts"; function corpus(): CorpusSnapshot { @@ -108,6 +109,79 @@ async function createContactsFixture(root: string): Promise { } describe("messagelikeme CLI", () => { + test("ingests a strict local bundle and exposes redacted source health", async () => { + const root = await mkdtemp(join(tmpdir(), "message-like-me-cli-bundle-")); + const capture = ioCapture(); + try { + const bundlePath = await writeSyntheticMessageBundle(root); + const state = join(root, "state"); + expect(await main([ + "--data-dir", state, "ingest", "bundle", "--input", bundlePath, "--json", + ], capture.io)).toBe(0); + expect(capture.stderr()).toBe(""); + const receipt = JSON.parse(capture.stdout()) as { + sources: Array<{ id: string }>; + messages: number; + }; + expect(receipt.messages).toBe(4); + expect(receipt.sources[0]!.id).toMatch(/^source_[a-f0-9]{64}$/u); + expect(capture.stdout()).not.toContain("Synthetic Peer"); + expect(capture.stdout()).not.toContain("peer@example.test"); + expect(capture.stdout()).not.toContain("Synthetic answer"); + + capture.clear(); + expect(await main([ + "--data-dir", state, "sources", "list", "--json", + ], capture.io)).toBe(0); + const listed = JSON.parse(capture.stdout()) as { + sources: Array>; + }; + expect(listed.sources[0]).toMatchObject({ + id: receipt.sources[0]!.id, + provider: "beeper", + network: "whatsapp", + conversations: 1, + messages: 4, + reactions: 2, + undatedReactions: 1, + }); + expect(listed.sources[0]).not.toHaveProperty("accountId"); + expect(capture.stdout()).not.toContain("synthetic-connected-account"); + + capture.clear(); + expect(await main([ + "--data-dir", state, "contacts", "list", "--json", + ], capture.io)).toBe(0); + const contacts = JSON.parse(capture.stdout()) as { contacts: Array<{ id: string }> }; + expect(contacts.contacts).toHaveLength(1); + capture.clear(); + expect(await main([ + "--data-dir", state, "inspect", "tempo", contacts.contacts[0]!.id, "--json", + ], capture.io)).toBe(0); + expect(JSON.parse(capture.stdout())).toMatchObject({ + reactions: { + total: 2, + incoming: 1, + outgoing: 1, + undated: 1, + byBody: [ + { body: "heart", total: 1 }, + { body: "thumbs-up", total: 1 }, + ], + }, + }); + + capture.clear(); + expect(await main([ + "--data-dir", state, "sources", "show", receipt.sources[0]!.id, + "--private", "--json", + ], capture.io)).toBe(0); + expect(capture.stdout()).toContain("synthetic-connected-account"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + test("keeps aggregate views redacted and completes packet-to-profile flow", async () => { const root = await mkdtemp(join(tmpdir(), "message-like-me-cli-")); const paths = await initializeDataPaths(dataPaths(join(root, "state"))); diff --git a/src/commands.ts b/src/commands.ts index 123a137..870905e 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -2,6 +2,7 @@ import { lstat } from "node:fs/promises"; import { isAbsolute, resolve } from "node:path"; import { integerOption, parseArguments, rejectUnused, type ParsedArguments } from "./args.ts"; import { prettyJson, sha256 } from "./canonical-json.ts"; +import { readMessageBundle } from "./bundle.ts"; import { DEFAULT_CONTACTS_DIRECTORY, readMacOSContacts } from "./contacts.ts"; import { CliError } from "./errors.ts"; import { DEFAULT_IMESSAGE_DATABASE, readIMessageDatabase } from "./imessage.ts"; @@ -29,7 +30,10 @@ export const HELP = `Message Like Me ${MESSAGE_LIKE_ME_VERSION} Usage: messagelikeme [--data-dir PATH] init [--json] messagelikeme [--data-dir PATH] ingest imessage [--database PATH] [--json] + messagelikeme [--data-dir PATH] ingest bundle --input ABS_PATH [--json] messagelikeme [--data-dir PATH] ingest contacts [--addressbook PATH] [--json] + messagelikeme [--data-dir PATH] sources list [--private] [--json] + messagelikeme [--data-dir PATH] sources show SOURCE_ID [--private] [--json] messagelikeme [--data-dir PATH] contacts list [--min-outgoing N] [--limit N] [--private] [--json] messagelikeme [--data-dir PATH] contacts show CONTACT_ID [--private] [--json] messagelikeme [--data-dir PATH] contacts resolve QUERY --private [--limit N] [--json] @@ -50,9 +54,9 @@ Usage: [--project PATH] [--force] [--json] messagelikeme [--data-dir PATH] doctor [--json] -Message Like Me reads caller-owned macOS Messages and optional Contacts data, -then stores private analysis locally. It has no network, account, AI-provider, -or message-sending surface. +Message Like Me reads caller-owned macOS Messages, optional Contacts data, and +strict private local message bundles, then stores private analysis locally. It +has no network, account, AI-provider, or message-sending surface. `; async function exists(path: string): Promise { @@ -124,7 +128,10 @@ function contactMetrics( options: Parameters[3] = {}, ): ContactMetrics { const evidence = contactEvidence(store, contactId); - return analyzeContact(evidence.messages, evidence.corpusRevision, contactId, options); + return analyzeContact(evidence.messages, evidence.corpusRevision, contactId, { + ...options, + reactionFacts: evidence.reactions, + }); } function metricOptions(parsed: ParsedArguments): Readonly<{ @@ -165,6 +172,7 @@ function safeContactDetail(store: LocalStore, contactId: string, privateLabels: privateParticipants: conversation.privateParticipants, } : {}), service: conversation.service, + services: conversation.services, group: conversation.group, participantCount: conversation.participantCount, participantIds: conversation.participantIds, @@ -240,6 +248,22 @@ function translateContactsError(error: unknown): never { ); } +function translateBundleError(error: unknown): never { + if (error instanceof CliError) throw error; + const code = (error as NodeJS.ErrnoException).code; + if (code === "EACCES" || code === "EPERM") { + throw new CliError("permission", "The selected private bundle is not readable", { cause: error }); + } + if (code === "ENOENT") { + throw new CliError("not-found", "The selected private bundle does not exist", { cause: error }); + } + throw new CliError( + "invalid-data", + "The selected private message bundle could not be read safely", + { cause: error }, + ); +} + export async function runCommand(argv: readonly string[], io: CommandIo): Promise { const parsed = parseArguments(argv); if (parsed.flags.has("version")) { @@ -299,6 +323,38 @@ export async function runCommand(argv: readonly string[], io: CommandIo): Promis return; } + if (command === "ingest" && subcommand === "bundle" && identifier === undefined) { + rejectUnused(parsed, ["data-dir", "input"], ["json"]); + const input = absolutePrivatePath(parsed.options.get("input"), "--input"); + const context = await writableStore(parsed); + try { + let bundle; + try { + bundle = await readMessageBundle(input, { hmacKey: context.key }); + } catch (error) { + translateBundleError(error); + } + const stored = context.store.replaceSources(bundle.sources, canonicalNow(io), context.key); + const result = { + schemaVersion: bundle.schemaVersion, + manifestSha256: bundle.manifestSha256, + corpusRevision: stored.corpusRevision, + sources: stored.sources, + conversations: stored.sources.reduce((sum, source) => sum + source.conversations, 0), + messages: stored.sources.reduce((sum, source) => sum + source.messages, 0), + }; + emit( + io, + json, + result, + `Ingested ${result.messages} active messages across ${result.conversations} conversations from ${result.sources.length} sources`, + ); + } finally { + context.store.close(); + } + return; + } + if (command === "ingest" && subcommand === "contacts" && identifier === undefined) { rejectUnused(parsed, ["data-dir", "addressbook"], ["json"]); const context = await writableStore(parsed); @@ -351,6 +407,31 @@ export async function runCommand(argv: readonly string[], io: CommandIo): Promis return; } + if (command === "sources" && subcommand === "list" && identifier === undefined) { + rejectUnused(parsed, ["data-dir"], ["json", "private"]); + const context = await existingStore(parsed); + try { + const sources = context.store.listSources(parsed.flags.has("private")); + emit(io, json, { sources }, `${sources.length} message sources`); + } finally { + context.store.close(); + } + return; + } + + if (command === "sources" && subcommand === "show" && identifier !== undefined) { + rejectUnused(parsed, ["data-dir"], ["json", "private"]); + const context = await existingStore(parsed); + try { + const source = context.store.source(identifier, parsed.flags.has("private")); + if (source === null) throw new CliError("not-found", `Unknown source ${identifier}`); + emit(io, json, source, `Message source ${identifier}`); + } finally { + context.store.close(); + } + return; + } + if (command === "contacts" && subcommand === "show" && identifier !== undefined) { rejectUnused(parsed, ["data-dir"], ["json", "private"]); const context = await existingStore(parsed); @@ -435,7 +516,7 @@ export async function runCommand(argv: readonly string[], io: CommandIo): Promis evidence.messages, evidence.corpusRevision, identifier, - metricOptions(parsed), + { ...metricOptions(parsed), reactionFacts: evidence.reactions }, ); const packet = buildStudyPacket(evidence.messages, metrics, { limit: integerOption(parsed, "limit", 24, 1, 50), @@ -510,7 +591,7 @@ export async function runCommand(argv: readonly string[], io: CommandIo): Promis evidence.messages, evidence.corpusRevision, identifier, - metricOptions(parsed), + { ...metricOptions(parsed), reactionFacts: evidence.reactions }, ); const packets = buildEvaluationPackets(evidence.messages, metrics, { after, diff --git a/src/fixtures/beeper-message-like-me-v1/accounts.ndjson b/src/fixtures/beeper-message-like-me-v1/accounts.ndjson new file mode 100644 index 0000000..d0d5e48 --- /dev/null +++ b/src/fixtures/beeper-message-like-me-v1/accounts.ndjson @@ -0,0 +1 @@ +{"accountId":"account:synthetic:primary","displayName":"Synthetic Primary","handle":"+15555550100","id":"account:synthetic:primary","kind":"account","network":"synthetic","provenance":{"connectedAccountProviderId":"beeper-account:synthetic-primary","observedAt":"2026-08-21T15:59:00.000Z","providerId":"beeper-account:synthetic-primary","providerRevision":null},"schemaVersion":1,"selfParticipantId":"participant:synthetic:self"} diff --git a/src/fixtures/beeper-message-like-me-v1/conversations.ndjson b/src/fixtures/beeper-message-like-me-v1/conversations.ndjson new file mode 100644 index 0000000..7f4f218 --- /dev/null +++ b/src/fixtures/beeper-message-like-me-v1/conversations.ndjson @@ -0,0 +1 @@ +{"accountId":"account:synthetic:primary","id":"conversation:synthetic:friend","kind":"conversation","lastMessageAt":"2026-08-21T15:58:45.000Z","network":"synthetic","participantIds":["participant:synthetic:self","participant:synthetic:peer"],"participantsComplete":true,"provenance":{"connectedAccountProviderId":"beeper-account:synthetic-primary","observedAt":"2026-08-21T15:59:00.000Z","providerId":"beeper-conversation:synthetic-friend","providerRevision":"chat-r1"},"schemaVersion":1,"startedAt":"2026-08-21T15:50:00.000Z","title":"Synthetic Friend","type":"direct"} diff --git a/src/fixtures/beeper-message-like-me-v1/manifest.json b/src/fixtures/beeper-message-like-me-v1/manifest.json new file mode 100644 index 0000000..0aca85e --- /dev/null +++ b/src/fixtures/beeper-message-like-me-v1/manifest.json @@ -0,0 +1 @@ +{"artifacts":[{"bytes":430,"mediaType":"application/x-ndjson","path":"accounts.ndjson","recordKind":"account","records":1,"sha256":"5e29e5ca806fc4a97c22e63d0061c722ce0f62f14e96383e70c15eaff57ee1f8"},{"bytes":797,"mediaType":"application/x-ndjson","path":"participants.ndjson","recordKind":"participant","records":2,"sha256":"a7fb621e298c45f481a3d2f3995cfcbea80693f52c7787c1ff05b69248134186"},{"bytes":571,"mediaType":"application/x-ndjson","path":"conversations.ndjson","recordKind":"conversation","records":1,"sha256":"87957914b3f21ece69815719db37ea6413efdf3201b68bd60a0cf7abbbacb529"},{"bytes":1492,"mediaType":"application/x-ndjson","path":"messages.ndjson","recordKind":"message","records":2,"sha256":"dbe97f5b4a5c46c6c2f3e02294511ceb7868e45a8738f8736c0ec93d6707c394"},{"bytes":521,"mediaType":"application/x-ndjson","path":"reactions.ndjson","recordKind":"reaction","records":1,"sha256":"683c263ce93e82bca0d905bcf6ef4cec6b32d8028aaa52595d0f295f068a6b01"},{"bytes":539,"mediaType":"application/x-ndjson","path":"tombstones.ndjson","recordKind":"tombstone","records":1,"sha256":"34f5a61aa9756c10979372b6147337053a563a6a7c63cd44eab602e2594e05d5"}],"completeness":{"kind":"truncated","observedFrom":"2026-08-21T15:50:00.000Z","observedThrough":"2026-08-21T15:59:00.000Z","reason":"explicit-source-limit"},"counts":{"account":1,"conversation":1,"message":2,"participant":2,"reaction":1,"tombstone":1},"format":"message-like-me.local-message-bundle","integrity":{"algorithm":"sha256","bundleSha256":"56c6ff3bbe60acfd103f234592087a9753d4b90447d0a508575e0e7c5d4cf514"},"privacy":{"attachments":"metadata-only","classification":"private-local","credentials":"excluded","providerUrls":"excluded"},"provider":{"id":"beeper","version":"0.6.2"},"schemaVersion":1,"source":{"id":"beeper-local","version":"1.0.0"},"timestamps":{"createdAt":"2026-08-21T16:00:01.000Z","finishedAt":"2026-08-21T16:00:01.000Z","startedAt":"2026-08-21T16:00:00.000Z"},"warnings":["attachments-metadata-only","remote-history-not-claimed","synthetic-golden-fixture"]} diff --git a/src/fixtures/beeper-message-like-me-v1/messages.ndjson b/src/fixtures/beeper-message-like-me-v1/messages.ndjson new file mode 100644 index 0000000..86c0af4 --- /dev/null +++ b/src/fixtures/beeper-message-like-me-v1/messages.ndjson @@ -0,0 +1,2 @@ +{"accountId":"account:synthetic:primary","attachments":[],"body":"edited synthetic reply","bodyTruncated":false,"conversationId":"conversation:synthetic:friend","deletion":null,"direction":"outgoing","edit":{"editedAt":"2026-08-21T15:58:30.000Z","kind":"in-place","providerRevision":"edit-r2"},"id":"message:synthetic:edited","kind":"message","network":"synthetic","provenance":{"connectedAccountProviderId":"beeper-account:synthetic-primary","observedAt":"2026-08-21T15:59:00.000Z","providerId":"beeper-message:synthetic-edited","providerRevision":"edit-r2"},"replyTo":{"messageId":null,"providerId":"beeper-message:synthetic-external-reply-target"},"schemaVersion":1,"senderParticipantId":"participant:synthetic:self","sentAt":"2026-08-21T15:58:00.000Z","sortKey":"00000000000000000001"} +{"accountId":"account:synthetic:primary","attachments":[],"body":null,"bodyTruncated":false,"conversationId":"conversation:synthetic:friend","deletion":{"observedAt":"2026-08-21T15:59:00.000Z","providerRevision":"delete-r3","state":"revoked"},"direction":"incoming","edit":null,"id":"message:synthetic:deleted","kind":"message","network":"synthetic","provenance":{"connectedAccountProviderId":"beeper-account:synthetic-primary","observedAt":"2026-08-21T15:59:00.000Z","providerId":"beeper-message:synthetic-deleted","providerRevision":"delete-r3"},"replyTo":null,"schemaVersion":1,"senderParticipantId":"participant:synthetic:peer","sentAt":"2026-08-21T15:58:45.000Z","sortKey":"00000000000000000002"} diff --git a/src/fixtures/beeper-message-like-me-v1/participants.ndjson b/src/fixtures/beeper-message-like-me-v1/participants.ndjson new file mode 100644 index 0000000..6a867b6 --- /dev/null +++ b/src/fixtures/beeper-message-like-me-v1/participants.ndjson @@ -0,0 +1,2 @@ +{"accountId":"account:synthetic:primary","displayName":"Synthetic Self","handle":"+15555550100","id":"participant:synthetic:self","isSelf":true,"kind":"participant","network":"synthetic","provenance":{"connectedAccountProviderId":"beeper-account:synthetic-primary","observedAt":"2026-08-21T15:59:00.000Z","providerId":"beeper-participant:synthetic-self","providerRevision":null},"schemaVersion":1} +{"accountId":"account:synthetic:primary","displayName":"Synthetic Peer","handle":"+15555550101","id":"participant:synthetic:peer","isSelf":false,"kind":"participant","network":"synthetic","provenance":{"connectedAccountProviderId":"beeper-account:synthetic-primary","observedAt":"2026-08-21T15:59:00.000Z","providerId":"beeper-participant:synthetic-peer","providerRevision":null},"schemaVersion":1} diff --git a/src/fixtures/beeper-message-like-me-v1/reactions.ndjson b/src/fixtures/beeper-message-like-me-v1/reactions.ndjson new file mode 100644 index 0000000..37e789a --- /dev/null +++ b/src/fixtures/beeper-message-like-me-v1/reactions.ndjson @@ -0,0 +1 @@ +{"accountId":"account:synthetic:primary","body":"👍","id":"reaction:synthetic:undated","kind":"reaction","messageId":"message:synthetic:edited","messageProviderId":"beeper-message:synthetic-edited","network":"synthetic","participantId":"participant:synthetic:peer","provenance":{"connectedAccountProviderId":"beeper-account:synthetic-primary","observedAt":"2026-08-21T15:59:00.000Z","providerId":"beeper-reaction:synthetic-undated","providerRevision":"reaction-r1"},"reactedAt":null,"schemaVersion":1,"state":"active"} diff --git a/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson b/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson new file mode 100644 index 0000000..e3dcebe --- /dev/null +++ b/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson @@ -0,0 +1 @@ +{"accountId":"account:synthetic:primary","deletedAt":"2026-08-21T15:59:00.000Z","entityId":"message:synthetic:deleted","entityKind":"message","entityProviderId":"beeper-message:synthetic-deleted","id":"tombstone:synthetic:message","kind":"tombstone","network":"synthetic","provenance":{"connectedAccountProviderId":"beeper-account:synthetic-primary","observedAt":"2026-08-21T15:59:00.000Z","providerId":"beeper-tombstone:synthetic-message","providerRevision":"delete-r3"},"providerRevision":"delete-r3","schemaVersion":1,"scope":"remote"} diff --git a/src/metrics.test.ts b/src/metrics.test.ts index bdd2c68..3e2828d 100644 --- a/src/metrics.test.ts +++ b/src/metrics.test.ts @@ -22,13 +22,14 @@ function message( replyTo?: string | null; attachments?: number; retractedAt?: string | null; + conversationId?: string; }> = {}, ): CorpusMessage { return Object.freeze({ id, sourceRowId, sourceGuid: `source-${id}`, - conversationId: CONTACT_ID, + conversationId: options.conversationId ?? CONTACT_ID, sentAt, direction, body, @@ -184,8 +185,20 @@ describe("analyzeContact", () => { total: 2, incoming: 1, outgoing: 1, + unknownDirection: 0, + dated: 2, + undated: 0, outgoingReactionRatio: 0.125, + byBody: [{ + body: "unknown", + total: 2, + incoming: 1, + outgoing: 1, + unknownDirection: 0, + }], }); + expect(analyzeContact(messages, CORPUS_REVISION, CONTACT_ID, { reactionFacts: [] }).reactions) + .toEqual(metrics.reactions); expect(metrics.surface).toMatchObject({ outgoingTextMessages: 6, lowercaseStartsRatio: 0.833333, @@ -209,6 +222,40 @@ describe("analyzeContact", () => { .toThrow("repeat ID"); }); + test("never joins sessions, bursts, or responses across conversation boundaries", () => { + const messages = [ + message("thread-a-in", 1, "2024-06-01T00:00:00.000Z", "incoming", "Question in A?", { + conversationId: "thread-a", + }), + message("thread-b-out", 2, "2024-06-01T00:00:10.000Z", "outgoing", "Unrelated answer in B", { + conversationId: "thread-b", + }), + message("thread-a-out", 3, "2024-06-01T00:01:00.000Z", "outgoing", "Answer in A", { + conversationId: "thread-a", + }), + message("thread-b-in", 4, "2024-06-01T00:02:00.000Z", "incoming", "Question in B?", { + conversationId: "thread-b", + }), + ] as const; + + const metrics = analyzeContact(messages, CORPUS_REVISION, CONTACT_ID); + + expect(metrics.sessions).toHaveLength(2); + expect(metrics.bursts).toHaveLength(4); + expect(metrics.responses).toHaveLength(1); + expect(metrics.responses[0]).toMatchObject({ + incomingMessageIds: ["thread-a-in"], + outgoingMessageIds: ["thread-a-out"], + latencySeconds: 60, + }); + for (const response of metrics.responses) { + const ids = [...response.incomingMessageIds, ...response.outgoingMessageIds]; + const conversations = new Set(ids.map((id) => + messages.find((candidate) => candidate.id === id)!.conversationId)); + expect(conversations.size).toBe(1); + } + }); + test("returns well-defined empty distributions and rejects invalid bounds", () => { const metrics = analyzeContact([], CORPUS_REVISION, CONTACT_ID); expect(metrics.sessions).toEqual([]); @@ -220,7 +267,11 @@ describe("analyzeContact", () => { total: 0, incoming: 0, outgoing: 0, + unknownDirection: 0, + dated: 0, + undated: 0, outgoingReactionRatio: 0, + byBody: [], }); expect(() => analyzeContact([], CORPUS_REVISION, CONTACT_ID, { sessionGapSeconds: 30, @@ -228,6 +279,58 @@ describe("analyzeContact", () => { })).toThrow("cannot exceed"); }); + test("counts undated reaction facts without inventing timeline timestamps", () => { + const metrics = analyzeContact([ + message("outgoing-text", 1, "2026-08-21T12:00:00.000Z", "outgoing", "ok"), + ], CORPUS_REVISION, CONTACT_ID, { + reactionFacts: [ + { + id: "reaction-in", + externalId: "external-in", + targetExternalId: "target-1", + conversationId: "conversation_1", + direction: "incoming", + body: "heart", + reactedAt: null, + state: "active", + }, + { + id: "reaction-out", + externalId: "external-out", + targetExternalId: "target-1", + conversationId: "conversation_1", + direction: "outgoing", + body: "heart", + reactedAt: null, + state: "active", + }, + { + id: "reaction-unknown", + externalId: "external-unknown", + targetExternalId: "target-1", + conversationId: "conversation_1", + direction: null, + body: "question", + reactedAt: null, + state: "active", + }, + ], + }); + expect(metrics.reactions).toEqual({ + total: 3, + incoming: 1, + outgoing: 1, + unknownDirection: 1, + dated: 0, + undated: 3, + outgoingReactionRatio: 0.5, + byBody: [ + { body: "heart", total: 2, incoming: 1, outgoing: 1, unknownDirection: 0 }, + { body: "question", total: 1, incoming: 0, outgoing: 0, unknownDirection: 1 }, + ], + }); + }); + test("excludes retracted and system records from style and tempo evidence", () => { const messages = [ message("eligible-in", 1, "2024-05-01T00:00:00.000Z", "incoming", "Are you coming?"), @@ -278,7 +381,11 @@ describe("analyzeContact", () => { total: 0, incoming: 0, outgoing: 0, + unknownDirection: 0, + dated: 0, + undated: 0, outgoingReactionRatio: 0, + byBody: [], }); }); }); diff --git a/src/metrics.ts b/src/metrics.ts index f3eee57..a619347 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -7,6 +7,7 @@ import { type BurstMetric, type ContactMetrics, type CorpusMessage, + type CorpusReactionFact, type Direction, type EvaluationPromptPacket, type EvaluationReferencePacket, @@ -79,6 +80,7 @@ type StudySelection = Readonly<{ export type AnalyzeContactOptions = Readonly<{ sessionGapSeconds?: number; burstGapSeconds?: number; + reactionFacts?: readonly CorpusReactionFact[]; }>; export type BuildStudyPacketOptions = Readonly<{ @@ -508,17 +510,74 @@ function tempoMetrics(messages: readonly OrderedMessage[], responses: readonly R }); } -function reactionMetrics(messages: readonly OrderedMessage[]): ReactionMetrics { - const reactions = messages.filter(({ message }) => - message.kind === "reaction" && message.retractedAt === null); - const outgoing = reactions.filter(({ message }) => message.direction === "outgoing").length; +function reactionMetrics( + messages: readonly OrderedMessage[], + facts: readonly CorpusReactionFact[] | undefined, +): ReactionMetrics { + const legacy = messages.filter(({ message }) => + message.kind === "reaction" && message.retractedAt === null).map(({ message }) => ({ + id: message.id, + externalId: message.sourceGuid, + targetExternalId: message.replyToSourceGuid ?? message.sourceGuid, + conversationId: message.conversationId, + direction: message.direction, + body: "unknown", + reactedAt: message.sentAt, + state: "active" as const, + })); + const merged = new Map(legacy.map((fact) => [fact.id, fact as CorpusReactionFact])); + for (const fact of facts ?? []) merged.set(fact.id, fact); + const source = [...merged.values()]; + const ids = new Set(); + const reactions = source.filter((fact, index) => { + if ( + typeof fact.id !== "string" + || fact.id.length === 0 + || ids.has(fact.id) + || (fact.direction !== null && fact.direction !== "incoming" && fact.direction !== "outgoing") + || typeof fact.body !== "string" + || (fact.state !== "active" && fact.state !== "removed") + ) throw new Error(`reactionFacts[${index}] is invalid`); + if (fact.reactedAt !== null) canonicalTimestamp(fact.reactedAt, `reactionFacts[${index}].reactedAt`); + ids.add(fact.id); + return fact.state === "active"; + }); + const outgoing = reactions.filter(({ direction }) => direction === "outgoing").length; + const incoming = reactions.filter(({ direction }) => direction === "incoming").length; + const unknownDirection = reactions.length - outgoing - incoming; const outgoingActions = messages.filter(({ message }) => - message.direction === "outgoing" && timelineEligible(message)).length; + message.kind !== "reaction" + && message.direction === "outgoing" + && timelineEligible(message)).length + outgoing; + const bodies = new Map(); + for (const reaction of reactions) { + const counts = bodies.get(reaction.body) ?? { + total: 0, + incoming: 0, + outgoing: 0, + unknownDirection: 0, + }; + counts.total += 1; + if (reaction.direction === "incoming") counts.incoming += 1; + else if (reaction.direction === "outgoing") counts.outgoing += 1; + else counts.unknownDirection += 1; + bodies.set(reaction.body, counts); + } return Object.freeze({ total: reactions.length, - incoming: reactions.length - outgoing, + incoming, outgoing, + unknownDirection, + dated: reactions.filter(({ reactedAt }) => reactedAt !== null).length, + undated: reactions.filter(({ reactedAt }) => reactedAt === null).length, outgoingReactionRatio: ratio(outgoing, outgoingActions), + byBody: Object.freeze([...bodies].map(([body, counts]) => Object.freeze({ body, ...counts })) + .sort((left, right) => right.total - left.total || left.body.localeCompare(right.body, "en-US"))), }); } @@ -549,19 +608,44 @@ export function analyzeContact( throw new Error("burstGapSeconds cannot exceed sessionGapSeconds"); } const ordered = orderedMessages(messages); - const sessions = sessionsFor(ordered, corpusRevision, contactId, sessionGapSeconds); - const burstRecords = burstsFor( - ordered, - sessions, - corpusRevision, - contactId, - burstGapSeconds, - ); - const responses = responsesFor( - burstRecords, - corpusRevision, - contactId, - ); + const byConversation = new Map(); + for (const row of ordered) { + const rows = byConversation.get(row.message.conversationId) ?? []; + rows.push(row); + byConversation.set(row.message.conversationId, rows); + } + const sessions: SessionMetric[] = []; + const burstRecords: BurstRecord[] = []; + const responses: ResponseEpisode[] = []; + for (const conversationId of [...byConversation.keys()].sort((left, right) => + left.localeCompare(right, "en-US"))) { + const rows = Object.freeze(byConversation.get(conversationId)!); + const conversationSessions = sessionsFor( + rows, + corpusRevision, + contactId, + sessionGapSeconds, + ); + const conversationBursts = burstsFor( + rows, + conversationSessions, + corpusRevision, + contactId, + burstGapSeconds, + ); + sessions.push(...conversationSessions); + burstRecords.push(...conversationBursts); + responses.push(...responsesFor(conversationBursts, corpusRevision, contactId)); + } + sessions.sort((left, right) => + left.startedAt.localeCompare(right.startedAt, "en-US") + || left.id.localeCompare(right.id, "en-US")); + burstRecords.sort((left, right) => + left.metric.startedAt.localeCompare(right.metric.startedAt, "en-US") + || left.metric.id.localeCompare(right.metric.id, "en-US")); + responses.sort((left, right) => + left.startedAt.localeCompare(right.startedAt, "en-US") + || left.id.localeCompare(right.id, "en-US")); return Object.freeze({ schemaVersion: METRICS_SCHEMA_VERSION, corpusRevision, @@ -575,11 +659,11 @@ export function analyzeContact( message.retractedAt === null && message.kind === "text" && message.body !== null).length, sessionGapSeconds, burstGapSeconds, - sessions, + sessions: Object.freeze(sessions), bursts: Object.freeze(burstRecords.map(({ metric }) => metric)), - responses, + responses: Object.freeze(responses), tempo: tempoMetrics(ordered, responses), - reactions: reactionMetrics(ordered), + reactions: reactionMetrics(ordered, options.reactionFacts), surface: surfaceMetrics(ordered), }); } @@ -870,7 +954,11 @@ function aggregateStudyMetrics(metrics: ContactMetrics): StudyAggregateMetrics { total: metrics.reactions.total, incoming: metrics.reactions.incoming, outgoing: metrics.reactions.outgoing, + unknownDirection: metrics.reactions.unknownDirection, + dated: metrics.reactions.dated, + undated: metrics.reactions.undated, outgoingReactionRatio: metrics.reactions.outgoingReactionRatio, + byBody: metrics.reactions.byBody, }), surface: Object.freeze({ outgoingTextMessages: metrics.surface.outgoingTextMessages, diff --git a/src/store.test.ts b/src/store.test.ts index 3f8cdb7..8ed583c 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -7,7 +7,13 @@ import { parseStyleProfile } from "./profile.ts"; import { contactHandleMatchId, normalizeContactHandle } from "./contacts.ts"; import { LocalStore } from "./store.ts"; import { syntheticProfile, syntheticProfileV2 } from "./test-fixtures.ts"; -import type { ContactsSnapshot, CorpusSnapshot } from "./types.ts"; +import type { + ContactsSnapshot, + CorpusMessage, + CorpusReactionFact, + CorpusSnapshot, + SourceCorpusSnapshot, +} from "./types.ts"; const CONTACTS_TEST_KEY = "synthetic-contacts-store-key-32"; @@ -90,6 +96,102 @@ function snapshotWithLaterMessage(revision: string): CorpusSnapshot { }; } +const BUNDLE_SOURCE_ID = `source_${"7".repeat(64)}`; +const BUNDLE_CONVERSATION_ID = "conversation_bundle_synthetic"; + +function bundleMessage( + id: string, + sourceRowId: number, + sentAt: string, + kind: CorpusMessage["kind"] = "text", +): CorpusMessage { + return { + id: `bundle-message-${id}`, + sourceRowId, + sourceGuid: `provider-message-${id}`, + conversationId: BUNDLE_CONVERSATION_ID, + sentAt, + direction: id === "a" ? "incoming" : "outgoing", + body: kind === "text" ? `Synthetic bundle ${id}.` : null, + bodySource: kind === "text" ? "text" : "unavailable", + kind, + replyToSourceGuid: null, + editedAt: null, + retractedAt: null, + service: "whatsapp", + attachmentCount: 0, + }; +} + +function bundleReaction(state: "active" | "removed" = "active"): CorpusReactionFact { + return { + id: "bundle-reaction-r", + externalId: "provider-reaction-r", + targetExternalId: "provider-message-a", + conversationId: BUNDLE_CONVERSATION_ID, + direction: "outgoing", + body: "heart", + reactedAt: null, + state, + }; +} + +function bundleSnapshot(options: Readonly<{ + revision: string; + generatedAt: string; + messages: readonly CorpusMessage[]; + reactions?: readonly CorpusReactionFact[]; + history?: "bounded" | "complete-current-local"; + deletions?: SourceCorpusSnapshot["deletions"]; +}>): SourceCorpusSnapshot { + return { + source: { + id: BUNDLE_SOURCE_ID, + kind: "bundle", + provider: "beeper", + network: "whatsapp", + accountId: "synthetic-connected-account", + externalId: "synthetic-connected-account", + revision: options.revision, + generatedAt: options.generatedAt, + producer: { id: "beeper-local", version: "test" }, + coverage: { + history: options.history ?? "bounded", + observedFrom: "2026-08-20T10:00:00.000Z", + observedTo: "2026-08-20T11:00:00.000Z", + kind: options.history === "complete-current-local" ? "complete-current-local" : "bounded-local", + reason: null, + }, + manifestSha256: options.revision, + identity: { synthetic: true }, + warnings: [], + }, + conversations: [{ + id: BUNDLE_CONVERSATION_ID, + sourceKey: "provider-conversation-bundle", + privateLabel: "Synthetic Bundle Contact", + service: "whatsapp", + participantCount: 1, + participantIds: ["participant-bundle-peer"], + privateParticipants: [], + group: false, + }], + conversationProvenance: [{ + conversationId: BUNDLE_CONVERSATION_ID, + externalId: "provider-conversation-bundle", + }], + messages: options.messages, + messageProvenance: options.messages.map((message) => ({ + messageId: message.id, + externalId: message.sourceGuid, + replyToExternalId: message.replyToSourceGuid, + attachments: [], + })), + reactionFacts: options.reactions ?? [], + deletions: options.deletions ?? [], + }; +} + function enrichmentCorpus(revision: string): CorpusSnapshot { const conversation = ( id: string, @@ -270,6 +372,31 @@ function createLegacyV1Store(path: string): void { } } +function createLegacyV2Store(path: string): void { + createLegacyV1Store(path); + const database = new Database(path, { strict: true }); + try { + database.exec(` + ALTER TABLE study_packets ADD COLUMN scope_id TEXT; + ALTER TABLE study_packets ADD COLUMN evidence_revision TEXT; + ALTER TABLE study_packets ADD COLUMN example_ids_json TEXT; + ALTER TABLE study_packets ADD COLUMN evidence_json TEXT; + ALTER TABLE profiles ADD COLUMN scope_id TEXT; + ALTER TABLE profiles ADD COLUMN evidence_revision TEXT; + CREATE TABLE conversation_contact_scopes( + conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE, + contact_id TEXT NOT NULL REFERENCES addressbook_contacts(id) ON DELETE CASCADE, + contacts_revision TEXT NOT NULL + ) STRICT; + CREATE INDEX conversation_contact_scopes_lookup + ON conversation_contact_scopes(contact_id,conversation_id); + PRAGMA user_version=2; + `); + } finally { + database.close(); + } +} + function contactsSnapshot(revision: string, label = "Synthetic Friend"): ContactsSnapshot { const handle = (value: string) => { const normalized = normalizeContactHandle(value)!; @@ -358,7 +485,7 @@ describe("local corpus store", () => { store.replaceCorpus(snapshot("d".repeat(64)), "2026-08-21T13:00:00.000Z"); expect(store.profile(profile.contactId)?.state).toBe("current"); expect(store.doctor()).toMatchObject({ - storeSchemaVersion: 2, + storeSchemaVersion: 3, quickCheck: "ok", foreignKeyViolations: 0, }); @@ -536,6 +663,8 @@ describe("local corpus store", () => { messageCount: 4, incomingCount: 2, outgoingCount: 2, + service: null, + services: ["SMS", "iMessage"], }); expect(store.conversation("email-conversation", true)?.id).toBe(personId); expect(store.contactCorpus(personId)?.messages.map(({ conversationId }) => conversationId)) @@ -800,6 +929,142 @@ describe("local corpus store", () => { } }); + test("merges bounded sources, applies explicit suppression, and rejects stale snapshots", async () => { + const root = await mkdtemp(join(tmpdir(), "message-like-me-source-merge-")); + const store = LocalStore.open(join(root, "store.sqlite3")); + const a = bundleMessage("a", 1, "2026-08-20T10:00:00.000Z"); + const b = bundleMessage("b", 2, "2026-08-20T10:01:00.000Z"); + try { + store.replaceCorpus(snapshot("a".repeat(64)), "2026-08-21T12:00:00.000Z"); + const first = bundleSnapshot({ + revision: "1".repeat(64), + generatedAt: "2026-08-21T12:01:00.000Z", + messages: [a, b], + reactions: [bundleReaction()], + }); + store.replaceSources([first], "2026-08-21T12:01:01.000Z"); + expect(store.listSources()).toHaveLength(2); + expect(store.contactCorpus(BUNDLE_CONVERSATION_ID)).toMatchObject({ + messages: [{ id: a.id }, { id: b.id }], + reactions: [{ id: "bundle-reaction-r", reactedAt: null, state: "active" }], + }); + + const retained = store.replaceSources([bundleSnapshot({ + revision: "2".repeat(64), + generatedAt: "2026-08-21T12:02:00.000Z", + messages: [a], + reactions: [bundleReaction()], + })], "2026-08-21T12:02:01.000Z"); + expect(retained.sources[0]?.changed).toBeFalse(); + expect(store.contactCorpus(BUNDLE_CONVERSATION_ID)?.messages.map(({ id }) => id)) + .toEqual([a.id, b.id]); + expect(store.source(BUNDLE_SOURCE_ID)).toMatchObject({ conversations: 1, messages: 2 }); + + store.replaceSources([bundleSnapshot({ + revision: "3".repeat(64), + generatedAt: "2026-08-21T12:03:00.000Z", + messages: [a], + reactions: [bundleReaction("removed")], + deletions: [ + { + entityKind: "message", + localEntityId: null, + externalId: b.sourceGuid, + deletedAt: "2026-08-21T12:03:00.000Z", + expectedConversationId: BUNDLE_CONVERSATION_ID, + reason: "tombstone", + }, + { + entityKind: "reaction", + localEntityId: "bundle-reaction-r", + externalId: "provider-reaction-r", + deletedAt: "2026-08-21T12:03:00.000Z", + expectedConversationId: BUNDLE_CONVERSATION_ID, + reason: "tombstone", + }, + ], + })], "2026-08-21T12:03:01.000Z"); + expect(store.contactCorpus(BUNDLE_CONVERSATION_ID)).toMatchObject({ + messages: [{ id: a.id }], + reactions: [], + }); + expect(store.source(BUNDLE_SOURCE_ID)).toMatchObject({ conversations: 1, messages: 1 }); + + const reappeared = bundleSnapshot({ + revision: "4".repeat(64), + generatedAt: "2026-08-21T12:04:00.000Z", + messages: [a, b], + reactions: [bundleReaction()], + }); + store.replaceSources([reappeared], "2026-08-21T12:04:01.000Z"); + expect(store.contactCorpus(BUNDLE_CONVERSATION_ID)).toMatchObject({ + messages: [{ id: a.id }, { id: b.id }], + reactions: [{ id: "bundle-reaction-r", state: "active" }], + }); + expect(store.source(BUNDLE_SOURCE_ID)).toMatchObject({ conversations: 1, messages: 2 }); + expect(() => store.replaceSources([first], "2026-08-21T12:05:00.000Z")) + .toThrow("older than stored state"); + expect(() => store.replaceSources([bundleSnapshot({ + ...reappeared, + revision: "5".repeat(64), + generatedAt: "2026-08-21T12:04:00.000Z", + messages: [a, b], + })], "2026-08-21T12:05:00.000Z")).toThrow("reuses generatedAt"); + expect(store.replaceSources([reappeared], "2026-08-21T12:05:00.000Z").sources[0]) + .toMatchObject({ changed: false, conversations: 1, messages: 2 }); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test("authoritative reaction absence and reappearance affect only that source", async () => { + const root = await mkdtemp(join(tmpdir(), "message-like-me-reaction-reappearance-")); + const store = LocalStore.open(join(root, "store.sqlite3")); + const a = bundleMessage("a", 1, "2026-08-20T10:00:00.000Z"); + const reactionMessage = { + ...bundleMessage("reaction-r", 2, "2026-08-20T10:00:30.000Z", "reaction"), + sourceGuid: "provider-reaction-r", + }; + const reaction = { ...bundleReaction(), id: reactionMessage.id }; + try { + store.replaceSources([bundleSnapshot({ + revision: "1".repeat(64), + generatedAt: "2026-08-21T12:01:00.000Z", + messages: [a, reactionMessage], + reactions: [reaction], + history: "complete-current-local", + })], "2026-08-21T12:01:01.000Z"); + expect(store.contactCorpus(BUNDLE_CONVERSATION_ID)?.reactions).toHaveLength(1); + + store.replaceSources([bundleSnapshot({ + revision: "2".repeat(64), + generatedAt: "2026-08-21T12:02:00.000Z", + messages: [a], + reactions: [], + history: "complete-current-local", + })], "2026-08-21T12:02:01.000Z"); + expect(store.contactCorpus(BUNDLE_CONVERSATION_ID)).toMatchObject({ + messages: [{ id: a.id }], + reactions: [], + }); + + store.replaceSources([bundleSnapshot({ + revision: "3".repeat(64), + generatedAt: "2026-08-21T12:03:00.000Z", + messages: [a, reactionMessage], + reactions: [reaction], + history: "complete-current-local", + })], "2026-08-21T12:03:01.000Z"); + expect(store.contactCorpus(BUNDLE_CONVERSATION_ID)?.reactions).toHaveLength(1); + expect(store.contactCorpus(BUNDLE_CONVERSATION_ID)?.messages.map(({ id }) => id)) + .toEqual([a.id, reactionMessage.id]); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + test("keeps a person profile current across unrelated changes and stales relevant evidence", async () => { const root = await mkdtemp(join(tmpdir(), "message-like-me-scope-revision-")); const store = LocalStore.open(join(root, "store.sqlite3")); @@ -934,7 +1199,7 @@ describe("local corpus store", () => { createLegacyV1Store(path); const store = LocalStore.open(path); try { - expect(store.doctor()).toMatchObject({ storeSchemaVersion: 2, profiles: 1 }); + expect(store.doctor()).toMatchObject({ storeSchemaVersion: 3, profiles: 1 }); expect(store.profile("contact_0123456789abcdef")).toMatchObject({ state: "current", profile: { schemaVersion: 1, corpusRevision: "a".repeat(64) }, @@ -945,7 +1210,7 @@ describe("local corpus store", () => { const migrated = new Database(path, { strict: true }); try { - expect(migrated.query("PRAGMA user_version").get()).toEqual({ user_version: 2 }); + expect(migrated.query("PRAGMA user_version").get()).toEqual({ user_version: 3 }); const profileColumns = migrated.query("PRAGMA table_info(profiles)").all() as Array<{ name: string }>; expect(profileColumns.map(({ name }) => name)).toContain("scope_id"); expect(profileColumns.map(({ name }) => name)).toContain("evidence_revision"); @@ -961,4 +1226,50 @@ describe("local corpus store", () => { await rm(root, { recursive: true, force: true }); } }); + + test("upgrades a populated v0.2 store in place without rebuilding evidence rows", async () => { + const root = await mkdtemp(join(tmpdir(), "message-like-me-v2-upgrade-")); + const path = join(root, "store.sqlite3"); + createLegacyV2Store(path); + const store = LocalStore.open(path); + try { + expect(store.doctor()).toMatchObject({ + storeSchemaVersion: 3, + conversations: 1, + messages: 1, + profiles: 1, + sources: 1, + }); + expect(store.corpusRevision()).toBe("a".repeat(64)); + expect(store.profile("contact_0123456789abcdef")).toMatchObject({ + state: "current", + profile: { schemaVersion: 1, corpusRevision: "a".repeat(64) }, + }); + } finally { + store.close(); + } + const migrated = new Database(path, { readonly: true, strict: true }); + try { + expect(migrated.query("PRAGMA user_version").get()).toEqual({ user_version: 3 }); + expect(migrated.query("SELECT count(*) AS value FROM conversations").get()) + .toEqual({ value: 1 }); + expect(migrated.query("SELECT count(*) AS value FROM messages").get()) + .toEqual({ value: 1 }); + expect(migrated.query("SELECT count(*) AS value FROM profiles").get()) + .toEqual({ value: 1 }); + expect(migrated.query("SELECT count(*) AS value FROM study_packets").get()) + .toEqual({ value: 1 }); + expect(migrated.query(`SELECT source_id,external_id FROM conversation_sources`).get()) + .toEqual({ source_id: "source_imessage_local", external_id: "legacy-conversation" }); + expect(migrated.query(`SELECT source_id,external_id FROM message_provenance`).get()) + .toEqual({ source_id: "source_imessage_local", external_id: "legacy-guid" }); + expect(migrated.query(`SELECT evidence_revision IS NOT NULL AS value FROM profiles`).get()) + .toEqual({ value: 1 }); + expect(migrated.query(`SELECT evidence_revision IS NOT NULL AS value FROM study_packets`).get()) + .toEqual({ value: 1 }); + } finally { + migrated.close(); + await rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/src/store.ts b/src/store.ts index f0f3d58..8100d5c 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,4 +1,5 @@ import { Database } from "bun:sqlite"; +import { createHash } from "node:crypto"; import { closeSync, constants as fsConstants, @@ -20,8 +21,11 @@ import type { ContactSummary, CorpusConversation, CorpusMessage, + CorpusReactionFact, CorpusSnapshot, + CorpusSourceDescriptor, ProfileEvidenceV2, + SourceCorpusSnapshot, StyleProfile, StyleProfileV2, } from "./types.ts"; @@ -29,8 +33,9 @@ import type { type Binding = string | number | bigint | Uint8Array | null; type Row = Record; -const STORE_SCHEMA_VERSION = 2; +const STORE_SCHEMA_VERSION = 3; const PERSON_SCOPE_PREFIX = "person_"; +export const IMESSAGE_SOURCE_ID = "source_imessage_local"; const SCHEMA = ` PRAGMA foreign_keys = ON; @@ -38,6 +43,23 @@ const SCHEMA = ` key TEXT PRIMARY KEY, value TEXT NOT NULL ) STRICT; + CREATE TABLE IF NOT EXISTS corpus_sources ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('imessage', 'bundle')), + provider TEXT NOT NULL, + network TEXT, + account_id TEXT, + external_id TEXT NOT NULL, + input_revision TEXT NOT NULL, + revision TEXT NOT NULL, + generated_at TEXT, + producer_json TEXT NOT NULL, + coverage_json TEXT NOT NULL, + manifest_sha256 TEXT, + identity_json TEXT NOT NULL, + warnings_json TEXT NOT NULL, + ingested_at TEXT NOT NULL + ) STRICT; CREATE TABLE IF NOT EXISTS conversations ( id TEXT PRIMARY KEY, source_key TEXT NOT NULL, @@ -48,6 +70,15 @@ const SCHEMA = ` private_participants_json TEXT NOT NULL, is_group INTEGER NOT NULL CHECK (is_group IN (0, 1)) ) STRICT; + CREATE TABLE IF NOT EXISTS conversation_sources ( + conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE, + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE RESTRICT, + external_id TEXT NOT NULL, + metadata_json TEXT NOT NULL, + UNIQUE (source_id, external_id) + ) STRICT; + CREATE INDEX IF NOT EXISTS conversation_sources_lookup + ON conversation_sources(source_id, conversation_id); CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, source_row_id INTEGER NOT NULL, @@ -68,6 +99,54 @@ const SCHEMA = ` CREATE INDEX IF NOT EXISTS messages_conversation_time ON messages(conversation_id, sent_at, source_row_id, id); CREATE INDEX IF NOT EXISTS messages_source_guid ON messages(source_guid); + CREATE TABLE IF NOT EXISTS message_provenance ( + message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE RESTRICT, + external_id TEXT NOT NULL, + reply_to_external_id TEXT, + attachments_json TEXT NOT NULL, + metadata_json TEXT NOT NULL, + UNIQUE (source_id, external_id) + ) STRICT; + CREATE INDEX IF NOT EXISTS message_provenance_source + ON message_provenance(source_id, message_id); + CREATE TABLE IF NOT EXISTS corpus_reaction_facts ( + id TEXT PRIMARY KEY, + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE CASCADE, + external_id TEXT NOT NULL, + target_external_id TEXT NOT NULL, + conversation_id TEXT REFERENCES conversations(id) ON DELETE SET NULL, + direction TEXT CHECK (direction IN ('incoming','outgoing')), + body TEXT NOT NULL, + reacted_at TEXT, + state TEXT NOT NULL CHECK (state IN ('active','removed')), + UNIQUE (source_id, external_id) + ) STRICT; + CREATE INDEX IF NOT EXISTS corpus_reaction_facts_source + ON corpus_reaction_facts(source_id,conversation_id,id); + CREATE TABLE IF NOT EXISTS corpus_source_records ( + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK ( + kind IN ('account','participant','reaction','tombstone','excluded-message') + ), + external_id TEXT NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (source_id, kind, external_id) + ) WITHOUT ROWID, STRICT; + CREATE TABLE IF NOT EXISTS corpus_source_suppressions ( + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK ( + kind IN ('conversation','message','reaction','reaction-timeline','participant','account') + ), + local_id TEXT NOT NULL, + external_id TEXT NOT NULL, + suppressed_at TEXT NOT NULL, + reason TEXT NOT NULL CHECK ( + reason IN ('authoritative-absence','tombstone','explicit-exclusion','replacement','reappeared') + ), + suppressed INTEGER NOT NULL CHECK (suppressed IN (0,1)), + PRIMARY KEY (source_id, kind, local_id) + ) WITHOUT ROWID, STRICT; CREATE TABLE IF NOT EXISTS study_packets ( sha256 TEXT PRIMARY KEY, contact_id TEXT NOT NULL, @@ -125,6 +204,83 @@ const SCHEMA = ` ON conversation_contact_labels(normalized_label, conversation_id); `; +const SOURCE_SCHEMA = ` + CREATE TABLE IF NOT EXISTS corpus_sources ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('imessage', 'bundle')), + provider TEXT NOT NULL, + network TEXT, + account_id TEXT, + external_id TEXT NOT NULL, + input_revision TEXT NOT NULL, + revision TEXT NOT NULL, + generated_at TEXT, + producer_json TEXT NOT NULL, + coverage_json TEXT NOT NULL, + manifest_sha256 TEXT, + identity_json TEXT NOT NULL, + warnings_json TEXT NOT NULL, + ingested_at TEXT NOT NULL + ) STRICT; + CREATE TABLE IF NOT EXISTS conversation_sources ( + conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE, + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE RESTRICT, + external_id TEXT NOT NULL, + metadata_json TEXT NOT NULL, + UNIQUE (source_id, external_id) + ) STRICT; + CREATE INDEX IF NOT EXISTS conversation_sources_lookup + ON conversation_sources(source_id, conversation_id); + CREATE TABLE IF NOT EXISTS message_provenance ( + message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE RESTRICT, + external_id TEXT NOT NULL, + reply_to_external_id TEXT, + attachments_json TEXT NOT NULL, + metadata_json TEXT NOT NULL, + UNIQUE (source_id, external_id) + ) STRICT; + CREATE INDEX IF NOT EXISTS message_provenance_source + ON message_provenance(source_id, message_id); + CREATE TABLE IF NOT EXISTS corpus_reaction_facts ( + id TEXT PRIMARY KEY, + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE CASCADE, + external_id TEXT NOT NULL, + target_external_id TEXT NOT NULL, + conversation_id TEXT REFERENCES conversations(id) ON DELETE SET NULL, + direction TEXT CHECK (direction IN ('incoming','outgoing')), + body TEXT NOT NULL, + reacted_at TEXT, + state TEXT NOT NULL CHECK (state IN ('active','removed')), + UNIQUE (source_id, external_id) + ) STRICT; + CREATE INDEX IF NOT EXISTS corpus_reaction_facts_source + ON corpus_reaction_facts(source_id,conversation_id,id); + CREATE TABLE IF NOT EXISTS corpus_source_records ( + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK ( + kind IN ('account','participant','reaction','tombstone','excluded-message') + ), + external_id TEXT NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (source_id, kind, external_id) + ) WITHOUT ROWID, STRICT; + CREATE TABLE IF NOT EXISTS corpus_source_suppressions ( + source_id TEXT NOT NULL REFERENCES corpus_sources(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK ( + kind IN ('conversation','message','reaction','reaction-timeline','participant','account') + ), + local_id TEXT NOT NULL, + external_id TEXT NOT NULL, + suppressed_at TEXT NOT NULL, + reason TEXT NOT NULL CHECK ( + reason IN ('authoritative-absence','tombstone','explicit-exclusion','replacement','reappeared') + ), + suppressed INTEGER NOT NULL CHECK (suppressed IN (0,1)), + PRIMARY KEY (source_id, kind, local_id) + ) WITHOUT ROWID, STRICT; +`; + const CONTACT_SCOPE_SCHEMA = ` CREATE TABLE IF NOT EXISTS conversation_contact_scopes ( conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE, @@ -413,7 +569,15 @@ function personScope( SELECT association.conversation_id FROM conversation_contact_scopes association JOIN conversations conversation ON conversation.id=association.conversation_id + JOIN conversation_sources ownership ON ownership.conversation_id=conversation.id WHERE association.contact_id=? AND conversation.is_group=0 + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=ownership.source_id + AND suppression.kind='conversation' + AND suppression.local_id=conversation.id + AND suppression.suppressed=1 + ) ORDER BY association.conversation_id `, addressBookContactId); if (rows.length === 0) return null; @@ -434,7 +598,15 @@ function analysisScope(database: Database, contactId: string): AnalysisScope | n } const conversation = get<{ id: string }>( database, - "SELECT id FROM conversations WHERE id=?", + `SELECT conversation.id FROM conversations conversation + JOIN conversation_sources ownership ON ownership.conversation_id=conversation.id + WHERE conversation.id=? AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=ownership.source_id + AND suppression.kind='conversation' + AND suppression.local_id=conversation.id + AND suppression.suppressed=1 + )`, contactId, ); if (conversation === null) return null; @@ -458,20 +630,37 @@ function messageRowsForScope( ): StoredMessageRow[] { if (exactConversationId !== undefined) { return all(database, ` - SELECT * FROM messages WHERE conversation_id=? - AND (? IS NULL OR sent_at>=?) AND (? IS NULL OR sent_at=?) AND (? IS NULL OR message.sent_at(database, ` SELECT message.* FROM messages message + JOIN message_provenance provenance ON provenance.message_id=message.id JOIN conversation_contact_scopes association ON association.conversation_id=message.conversation_id WHERE association.contact_id=? AND (? IS NULL OR message.sent_at>=?) AND (? IS NULL OR message.sent_at(database, ` - SELECT * FROM messages WHERE conversation_id=? - AND (? IS NULL OR sent_at>=?) AND (? IS NULL OR sent_at=?) AND (? IS NULL OR message.sent_at; + +function reactionFactsForScope( + database: Database, + scope: AnalysisScope, + window: EvidenceWindow = UNBOUNDED_EVIDENCE_WINDOW, +): CorpusReactionFact[] { + const select = `SELECT reaction.id,reaction.external_id,reaction.target_external_id, + reaction.conversation_id,reaction.direction,reaction.body,reaction.reacted_at,reaction.state + FROM corpus_reaction_facts reaction`; + const suppression = `NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=reaction.source_id + AND suppression.kind='reaction' + AND suppression.local_id=reaction.id + AND suppression.suppressed=1 + )`; + const rows = scope.kind === "person" + ? all(database, `${select} + JOIN conversation_contact_scopes association + ON association.conversation_id=reaction.conversation_id + WHERE association.contact_id=? AND reaction.state='active' AND ${suppression} + ORDER BY reaction.reacted_at IS NULL,reaction.reacted_at,reaction.id`, scope.addressBookContactId) + : all(database, `${select} + WHERE reaction.conversation_id=? AND reaction.state='active' AND ${suppression} + ORDER BY reaction.reacted_at IS NULL,reaction.reacted_at,reaction.id`, scope.conversationIds[0]!); + return rows.filter((row) => row.reacted_at === null + ? window.after === null && window.before === null + : (window.after === null || row.reacted_at >= window.after) + && (window.before === null || row.reacted_at < window.before)).map((row) => ({ + id: row.id, + externalId: row.external_id, + targetExternalId: row.target_external_id, + conversationId: row.conversation_id, + direction: row.direction, + body: row.body, + reactedAt: row.reacted_at, + state: row.state, + })); +} + function scopeEvidenceRevision( database: Database, scope: AnalysisScope, @@ -521,8 +769,18 @@ function scopeEvidenceRevision( ? scope.conversationIds : Object.freeze([exactConversationId]); const messages = messageRowsForScope(database, scope, exactConversationId, window).map(corpusMessage); + const reactions = reactionFactsForScope(database, scope, window); return sha256(canonicalJson( - window.after === null && window.before === null + reactions.length > 0 + ? { + schemaVersion: 3, + scopeId: scope.id, + conversationIds, + evidenceWindow: window, + messages, + reactions, + } + : window.after === null && window.before === null ? { schemaVersion: 1, scopeId: scope.id, @@ -577,16 +835,32 @@ function scopeMessageCounts(database: Database, scope: AnalysisScope): Readonly< outgoing_count: number; }>(database, `${select} FROM messages message + JOIN message_provenance provenance ON provenance.message_id=message.id JOIN conversation_contact_scopes association ON association.conversation_id=message.conversation_id - WHERE association.contact_id=?`, scope.addressBookContactId) + WHERE association.contact_id=? AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=provenance.source_id + AND suppression.local_id=message.id + AND suppression.kind IN ('message','reaction','reaction-timeline') + AND suppression.suppressed=1 + )`, scope.addressBookContactId) : get<{ first_message_at: string | null; last_message_at: string | null; message_count: number; incoming_count: number; outgoing_count: number; - }>(database, `${select} FROM messages message WHERE message.conversation_id=?`, scope.conversationIds[0]!); + }>(database, `${select} + FROM messages message + JOIN message_provenance provenance ON provenance.message_id=message.id + WHERE message.conversation_id=? AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=provenance.source_id + AND suppression.local_id=message.id + AND suppression.kind IN ('message','reaction','reaction-timeline') + AND suppression.suppressed=1 + )`, scope.conversationIds[0]!); return { firstMessageAt: row?.first_message_at ?? null, lastMessageAt: row?.last_message_at ?? null, @@ -637,6 +911,78 @@ function backfillLegacyEvidence(database: Database): void { } } +function backfillLegacySource(database: Database): void { + const conversations = get<{ value: number }>( + database, + "SELECT count(*) AS value FROM conversations", + )?.value ?? 0; + const assigned = get<{ value: number }>( + database, + "SELECT count(*) AS value FROM conversation_sources", + )?.value ?? 0; + if (assigned !== 0 && assigned !== conversations) { + throw new CliError("invalid-data", "Local store has partially assigned corpus source ownership"); + } + if (conversations === 0 || assigned === conversations) return; + const revision = scalarText(database, "corpus_revision"); + if (revision === null || !/^[a-f0-9]{64}$/u.test(revision)) { + throw new CliError("invalid-data", "Legacy local store has no valid corpus revision"); + } + const identity = scalarText(database, "source_identity") ?? canonicalJson({ migrated: true }); + const warnings = scalarText(database, "warnings") ?? canonicalJson([]); + const ingestedAt = scalarText(database, "ingested_at") ?? "1970-01-01T00:00:00.000Z"; + database.query(` + INSERT INTO corpus_sources( + id,kind,provider,network,account_id,external_id,input_revision,revision,generated_at, + producer_json,coverage_json,manifest_sha256,identity_json,warnings_json,ingested_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + `).run( + IMESSAGE_SOURCE_ID, + "imessage", + "apple", + null, + null, + "local-imessage", + revision, + revision, + null, + canonicalJson({ id: "message-like-me", version: "legacy" }), + canonicalJson({ history: "complete-current-local", observedFrom: null, observedTo: null }), + null, + identity, + warnings, + ingestedAt, + ); + database.exec(` + INSERT INTO conversation_sources(conversation_id,source_id,external_id,metadata_json) + SELECT id,'${IMESSAGE_SOURCE_ID}',source_key,'{}' FROM conversations; + `); + const rows = all<{ + id: string; + source_guid: string; + reply_to_source_guid: string | null; + attachment_count: number; + }>(database, ` + SELECT id,source_guid,reply_to_source_guid,attachment_count + FROM messages ORDER BY id + `); + const insert = database.query(` + INSERT INTO message_provenance( + message_id,source_id,external_id,reply_to_external_id,attachments_json,metadata_json + ) VALUES (?,?,?,?,?,?) + `); + for (const row of rows) { + insert.run( + row.id, + IMESSAGE_SOURCE_ID, + row.source_guid, + row.reply_to_source_guid, + canonicalJson({ count: row.attachment_count, detailsAvailable: false }), + canonicalJson({ migrated: true }), + ); + } +} + function initializeStoreSchema(database: Database): void { const existingStore = tableExists(database, "metadata"); const version = userVersion(database); @@ -656,6 +1002,7 @@ function initializeStoreSchema(database: Database): void { throw new CliError("invalid-data", `Local store is missing required table ${table}`); } } + database.exec(SOURCE_SCHEMA); transaction(database, () => { database.exec(CONTACT_SCOPE_SCHEMA); database.exec(` @@ -671,6 +1018,7 @@ function initializeStoreSchema(database: Database): void { addColumn(database, "study_packets", "evidence_json TEXT"); addColumn(database, "profiles", "scope_id TEXT"); addColumn(database, "profiles", "evidence_revision TEXT"); + backfillLegacySource(database); backfillLegacyEvidence(database); database.exec(`PRAGMA user_version=${STORE_SCHEMA_VERSION}`); }); @@ -713,7 +1061,17 @@ function rebuildConversationLabels( owners.set(key, values); } const conversations = all<{ id: string; private_participants_json: string }>(database, ` - SELECT id,private_participants_json FROM conversations WHERE is_group=0 ORDER BY id + SELECT conversation.id,conversation.private_participants_json + FROM conversations conversation + JOIN conversation_sources ownership ON ownership.conversation_id=conversation.id + WHERE conversation.is_group=0 AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=ownership.source_id + AND suppression.kind='conversation' + AND suppression.local_id=conversation.id + AND suppression.suppressed=1 + ) + ORDER BY conversation.id `); const insertScope = database.query(`INSERT INTO conversation_contact_scopes( conversation_id,contact_id,contacts_revision @@ -849,6 +1207,251 @@ function hardenDatabaseFiles(path: string): void { } } +function globalCorpusRevision(database: Database): string | null { + const sources = all<{ + id: string; + kind: "imessage" | "bundle"; + input_revision: string; + revision: string; + }>(database, "SELECT id,kind,input_revision,revision FROM corpus_sources ORDER BY id"); + if (sources.length === 0) return null; + if ( + sources.length === 1 + && sources[0]!.id === IMESSAGE_SOURCE_ID + && sources[0]!.kind === "imessage" + ) return sources[0]!.input_revision; + return sha256(canonicalJson({ + schemaVersion: 1, + sources: sources.map(({ id, kind, revision }) => ({ id, kind, revision })), + })); +} + +function sourceStateRevision(database: Database, sourceId: string): string { + const hash = createHash("sha256"); + hash.update("message-like-me\0stored-source-state-v1\0", "utf8"); + const append = (kind: string, row: Row): void => { + const encoded = canonicalJson(row); + hash.update(`${kind.length}:${kind}${encoded.length}:`, "utf8").update(encoded, "utf8"); + }; + const source = get(database, ` + SELECT kind,provider,network,account_id,external_id,producer_json, + coverage_json,warnings_json + FROM corpus_sources WHERE id=? + `, sourceId); + if (source === null) throw new CliError("internal", `Missing corpus source ${sourceId}`); + append("source", source); + for (const row of database.query(` + SELECT conversation.id,conversation.source_key,conversation.private_label, + conversation.service,conversation.participant_count, + conversation.participant_ids_json,conversation.private_participants_json, + conversation.is_group + FROM conversation_sources ownership + JOIN conversations conversation ON conversation.id=ownership.conversation_id + WHERE ownership.source_id=? + ORDER BY ownership.external_id,conversation.id + `).iterate(sourceId) as Iterable) append("conversation", row); + for (const row of database.query(` + SELECT message.id,message.source_row_id,message.source_guid,message.conversation_id, + message.sent_at,message.direction,message.body,message.body_source,message.kind, + message.reply_to_source_guid,message.edited_at,message.retracted_at,message.service, + message.attachment_count,provenance.external_id, + provenance.reply_to_external_id,provenance.attachments_json + FROM message_provenance provenance + JOIN messages message ON message.id=provenance.message_id + WHERE provenance.source_id=? + ORDER BY provenance.external_id,message.id + `).iterate(sourceId) as Iterable) append("message", row); + for (const row of database.query(` + SELECT id,external_id,target_external_id,conversation_id,direction,body,reacted_at,state + FROM corpus_reaction_facts WHERE source_id=? ORDER BY external_id,id + `).iterate(sourceId) as Iterable) append("reaction-fact", row); + for (const row of database.query(` + SELECT kind,local_id,external_id,reason FROM corpus_source_suppressions + WHERE source_id=? AND suppressed=1 ORDER BY kind,local_id + `).iterate(sourceId) as Iterable) append("suppression", row); + return hash.digest("hex"); +} + +function setCorpusRevision(database: Database): string | null { + const revision = globalCorpusRevision(database); + if (revision === null) { + database.query("DELETE FROM metadata WHERE key='corpus_revision'").run(); + return null; + } + database.query(` + INSERT INTO metadata(key,value) VALUES ('corpus_revision',?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value + `).run(revision); + return revision; +} + +function validSourceDescriptor(source: CorpusSourceDescriptor): void { + if ( + (source.id !== IMESSAGE_SOURCE_ID && !/^source_[a-f0-9]{64}$/u.test(source.id)) + || (source.kind !== "imessage" && source.kind !== "bundle") + || source.provider.length < 1 + || Buffer.byteLength(source.provider, "utf8") > 256 + || !/^[a-f0-9]{64}$/u.test(source.revision) + || source.externalId.length < 1 + || Buffer.byteLength(source.externalId, "utf8") > 4_096 + || source.warnings.length > 130 + ) throw new CliError("invalid-data", `Corpus source ${source.id} is invalid`); + canonicalTimestampOrNull(source.generatedAt, `Corpus source ${source.id} generatedAt`); + if (source.kind === "bundle" && source.generatedAt === null) { + throw new CliError("invalid-data", `Bundle source ${source.id} requires generatedAt`); + } + canonicalTimestampOrNull(source.coverage.observedFrom, `Corpus source ${source.id} observedFrom`); + canonicalTimestampOrNull(source.coverage.observedTo, `Corpus source ${source.id} observedTo`); + if ( + ( + source.coverage.observedFrom !== null + && source.coverage.observedTo !== null + && source.coverage.observedFrom > source.coverage.observedTo + ) + ) throw new CliError("invalid-data", `Corpus source ${source.id} has invalid coverage bounds`); + if ( + source.coverage.history !== "complete-current-local" + && source.coverage.history !== "bounded" + && source.coverage.history !== "unknown" + ) throw new CliError("invalid-data", `Corpus source ${source.id} has invalid history coverage`); + if ( + (source.coverage.kind !== undefined && ( + source.coverage.kind.length < 1 + || Buffer.byteLength(source.coverage.kind, "utf8") > 128 + || /\p{Cc}/u.test(source.coverage.kind) + )) + || (source.coverage.reason !== undefined && source.coverage.reason !== null && ( + source.coverage.reason.length < 1 + || Buffer.byteLength(source.coverage.reason, "utf8") > 128 + || /\p{Cc}/u.test(source.coverage.reason) + )) + ) throw new CliError("invalid-data", `Corpus source ${source.id} has invalid coverage metadata`); + if ( + source.manifestSha256 !== null + && !/^[a-f0-9]{64}$/u.test(source.manifestSha256) + ) throw new CliError("invalid-data", `Corpus source ${source.id} has an invalid manifest digest`); + if ( + source.producer.id.length < 1 + || source.producer.version.length < 1 + || Buffer.byteLength(source.producer.id, "utf8") > 256 + || Buffer.byteLength(source.producer.version, "utf8") > 256 + ) throw new CliError("invalid-data", `Corpus source ${source.id} has invalid producer identity`); + for (const warning of source.warnings) { + if (Buffer.byteLength(warning, "utf8") > 1_024 || warning.includes("\u0000")) { + throw new CliError("invalid-data", `Corpus source ${source.id} has an invalid warning`); + } + } +} + +function validateSourceSnapshot(snapshot: SourceCorpusSnapshot): void { + validSourceDescriptor(snapshot.source); + if ( + snapshot.conversations.length > 2_000_000 + || snapshot.messages.length > 2_000_000 + || (snapshot.reactionFacts?.length ?? 0) > 2_000_000 + || snapshot.conversationProvenance.length !== snapshot.conversations.length + || snapshot.messageProvenance.length !== snapshot.messages.length + ) throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} exceeds its result bounds`); + const conversationIds = new Set(snapshot.conversations.map(({ id }) => id)); + if (conversationIds.size !== snapshot.conversations.length) { + throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} repeats conversation IDs`); + } + const conversationProvenance = new Map( + snapshot.conversationProvenance.map((value) => [value.conversationId, value]), + ); + if ( + conversationProvenance.size !== snapshot.conversationProvenance.length + || [...conversationIds].some((id) => !conversationProvenance.has(id)) + ) throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has invalid conversation provenance`); + const externalConversations = new Set(); + for (const provenance of snapshot.conversationProvenance) { + if ( + provenance.externalId.length < 1 + || Buffer.byteLength(provenance.externalId, "utf8") > 4_096 + || externalConversations.has(provenance.externalId) + ) throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has invalid external conversation IDs`); + externalConversations.add(provenance.externalId); + } + const messageIds = new Set(snapshot.messages.map(({ id }) => id)); + if (messageIds.size !== snapshot.messages.length) { + throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} repeats message IDs`); + } + for (const message of snapshot.messages) { + if (!conversationIds.has(message.conversationId)) { + throw new CliError("invalid-data", `Message ${message.id} references an unknown conversation`); + } + } + const messageProvenance = new Map(snapshot.messageProvenance.map((value) => [value.messageId, value])); + if ( + messageProvenance.size !== snapshot.messageProvenance.length + || [...messageIds].some((id) => !messageProvenance.has(id)) + ) throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has invalid message provenance`); + const externalMessages = new Set(); + for (const provenance of snapshot.messageProvenance) { + if ( + provenance.externalId.length < 1 + || Buffer.byteLength(provenance.externalId, "utf8") > 4_096 + || externalMessages.has(provenance.externalId) + || provenance.attachments.length > 256 + ) throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has invalid external message provenance`); + externalMessages.add(provenance.externalId); + } + const auxiliaryIds = new Set(); + for (const record of snapshot.auxiliaryRecords ?? []) { + const key = `${record.kind}\0${record.id}`; + if ( + !["account", "participant", "reaction", "tombstone", "excluded-message"].includes(record.kind) + || record.id.length < 1 + || Buffer.byteLength(record.id, "utf8") > 4_096 + || auxiliaryIds.has(key) + ) throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has invalid auxiliary records`); + const encoded = canonicalJson(record.record); + if (typeof encoded !== "string" || Buffer.byteLength(encoded, "utf8") > 2 * 1024 * 1024) { + throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has an oversized auxiliary record`); + } + auxiliaryIds.add(key); + } + const reactionIds = new Set(); + const externalReactionIds = new Set(); + for (const reaction of snapshot.reactionFacts ?? []) { + if ( + reaction.id.length < 1 + || reaction.externalId.length < 1 + || reaction.targetExternalId.length < 1 + || Buffer.byteLength(reaction.id, "utf8") > 4_096 + || Buffer.byteLength(reaction.externalId, "utf8") > 4_096 + || Buffer.byteLength(reaction.targetExternalId, "utf8") > 4_096 + || Buffer.byteLength(reaction.body, "utf8") > 8 * 1_024 + || reactionIds.has(reaction.id) + || externalReactionIds.has(reaction.externalId) + || (reaction.conversationId !== null && !conversationIds.has(reaction.conversationId)) + || (reaction.direction !== null && reaction.direction !== "incoming" && reaction.direction !== "outgoing") + || (reaction.state !== "active" && reaction.state !== "removed") + ) throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has invalid reaction facts`); + canonicalTimestampOrNull(reaction.reactedAt, `Corpus source ${snapshot.source.id} reaction time`); + reactionIds.add(reaction.id); + externalReactionIds.add(reaction.externalId); + } + for (const deletion of snapshot.deletions ?? []) { + if ( + ![ + "account", "participant", "conversation", "message", "reaction", "reaction-timeline", + ].includes(deletion.entityKind) + || deletion.externalId.length < 1 + || Buffer.byteLength(deletion.externalId, "utf8") > 4_096 + || (deletion.localEntityId !== null && Buffer.byteLength(deletion.localEntityId, "utf8") > 4_096) + || (deletion.expectedConversationId !== undefined && ( + deletion.expectedConversationId.length < 1 + || Buffer.byteLength(deletion.expectedConversationId, "utf8") > 4_096 + )) + || (deletion.reason !== undefined && ![ + "tombstone", "explicit-exclusion", "replacement", + ].includes(deletion.reason)) + ) throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has an invalid deletion`); + canonicalTimestampOrNull(deletion.deletedAt, `Corpus source ${snapshot.source.id} deletion time`); + } +} + export class LocalStore { readonly #database: Database; @@ -886,8 +1489,10 @@ export class LocalStore { } sourceIdentity(): unknown | null { - const encoded = scalarText(this.#database, "source_identity"); - return encoded === null ? null : JSON.parse(encoded) as unknown; + const encoded = get<{ identity_json: string }>(this.#database, ` + SELECT identity_json FROM corpus_sources WHERE id=? + `, IMESSAGE_SOURCE_ID)?.identity_json ?? scalarText(this.#database, "source_identity"); + return encoded === null ? null : parsedJson(encoded, "Stored iMessage source identity"); } contactsRevision(): string | null { @@ -1027,98 +1632,719 @@ export class LocalStore { })); } - replaceCorpus( - snapshot: CorpusSnapshot, + replaceSources( + snapshots: readonly SourceCorpusSnapshot[], ingestedAt: string, hmacKey?: string | Uint8Array, ): Readonly<{ corpusRevision: string; - conversations: number; - messages: number; + sources: readonly Readonly<{ + id: string; + changed: boolean; + conversations: number; + messages: number; + }>[]; }> { - const corpusRevision = snapshot.source.snapshotSha256; - if (!/^[a-f0-9]{64}$/u.test(corpusRevision)) { - throw new CliError("invalid-data", "The iMessage reader returned an invalid corpus revision"); - } - const conversationIds = new Set(snapshot.conversations.map((conversation) => conversation.id)); - if (conversationIds.size !== snapshot.conversations.length) { - throw new CliError("invalid-data", "The iMessage reader returned duplicate conversation IDs"); + canonicalTimestampOrNull(ingestedAt, "Source ingest time"); + if (snapshots.length < 1) { + throw new CliError("invalid-data", "A source replacement must contain at least one source"); } - const messageIds = new Set(); - for (const message of snapshot.messages) { - if (!conversationIds.has(message.conversationId)) { - throw new CliError("invalid-data", `Message ${message.id} references an unknown conversation`); + const sourceIds = new Set(); + for (const snapshot of snapshots) { + if (sourceIds.has(snapshot.source.id)) { + throw new CliError("invalid-data", `Source replacement repeats ${snapshot.source.id}`); } - if (messageIds.has(message.id)) throw new CliError("invalid-data", `Duplicate message ID ${message.id}`); - messageIds.add(message.id); + sourceIds.add(snapshot.source.id); + validateSourceSnapshot(snapshot); } - transaction(this.#database, () => { - this.#database.exec("DELETE FROM messages; DELETE FROM conversations;"); - const insertConversation = this.#database.query(` - INSERT INTO conversations ( - id, source_key, private_label, service, participant_count, - participant_ids_json, private_participants_json, is_group - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + return transaction(this.#database, () => { + const upsertSource = this.#database.query(` + INSERT INTO corpus_sources( + id,kind,provider,network,account_id,external_id,input_revision,revision,generated_at, + producer_json,coverage_json,manifest_sha256,identity_json,warnings_json,ingested_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + kind=excluded.kind,provider=excluded.provider,network=excluded.network, + account_id=excluded.account_id,external_id=excluded.external_id, + input_revision=excluded.input_revision,generated_at=excluded.generated_at, + producer_json=excluded.producer_json,coverage_json=excluded.coverage_json, + manifest_sha256=excluded.manifest_sha256,identity_json=excluded.identity_json, + warnings_json=excluded.warnings_json,ingested_at=excluded.ingested_at `); - for (const conversation of snapshot.conversations) { - insertConversation.run( - conversation.id, - conversation.sourceKey, - conversation.privateLabel, - conversation.service, - conversation.participantCount, - canonicalJson(conversation.participantIds), - canonicalJson(conversation.privateParticipants), - conversation.group ? 1 : 0, - ); - } - const insertMessage = this.#database.query(` - INSERT INTO messages ( - id, source_row_id, source_guid, conversation_id, sent_at, direction, - body, body_source, kind, reply_to_source_guid, edited_at, retracted_at, - service, attachment_count - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + const upsertConversation = this.#database.query(` + INSERT INTO conversations( + id,source_key,private_label,service,participant_count, + participant_ids_json,private_participants_json,is_group + ) VALUES (?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + source_key=excluded.source_key,private_label=excluded.private_label, + service=excluded.service,participant_count=excluded.participant_count, + participant_ids_json=excluded.participant_ids_json, + private_participants_json=excluded.private_participants_json,is_group=excluded.is_group `); - for (const message of snapshot.messages) { - insertMessage.run( - message.id, - message.sourceRowId, - message.sourceGuid, - message.conversationId, - message.sentAt, - message.direction, - message.body, - message.bodySource, - message.kind, - message.replyToSourceGuid, - message.editedAt, - message.retractedAt, - message.service, - message.attachmentCount, + const upsertConversationSource = this.#database.query(` + INSERT INTO conversation_sources(conversation_id,source_id,external_id,metadata_json) + VALUES (?,?,?,?) + ON CONFLICT(conversation_id) DO UPDATE SET + external_id=excluded.external_id,metadata_json=excluded.metadata_json + `); + const upsertMessage = this.#database.query(` + INSERT INTO messages( + id,source_row_id,source_guid,conversation_id,sent_at,direction, + body,body_source,kind,reply_to_source_guid,edited_at,retracted_at, + service,attachment_count + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + source_guid=excluded.source_guid,conversation_id=excluded.conversation_id, + sent_at=excluded.sent_at,direction=excluded.direction,body=excluded.body, + body_source=excluded.body_source,kind=excluded.kind, + reply_to_source_guid=excluded.reply_to_source_guid,edited_at=excluded.edited_at, + retracted_at=excluded.retracted_at,service=excluded.service, + attachment_count=excluded.attachment_count + `); + const upsertMessageProvenance = this.#database.query(` + INSERT INTO message_provenance( + message_id,source_id,external_id,reply_to_external_id,attachments_json,metadata_json + ) VALUES (?,?,?,?,?,?) + ON CONFLICT(message_id) DO UPDATE SET + external_id=excluded.external_id,reply_to_external_id=excluded.reply_to_external_id, + attachments_json=excluded.attachments_json,metadata_json=excluded.metadata_json + `); + const upsertReactionFact = this.#database.query(` + INSERT INTO corpus_reaction_facts( + id,source_id,external_id,target_external_id,conversation_id, + direction,body,reacted_at,state + ) VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + external_id=excluded.external_id,target_external_id=excluded.target_external_id, + conversation_id=excluded.conversation_id,direction=excluded.direction, + body=excluded.body,reacted_at=excluded.reacted_at,state=excluded.state + `); + const upsertSourceRecord = this.#database.query(` + INSERT INTO corpus_source_records(source_id,kind,external_id,record_json) + VALUES (?,?,?,?) + ON CONFLICT(source_id,kind,external_id) DO UPDATE SET record_json=excluded.record_json + `); + const setSuppression = this.#database.query(` + INSERT INTO corpus_source_suppressions( + source_id,kind,local_id,external_id,suppressed_at,reason,suppressed + ) VALUES (?,?,?,?,?,?,?) + ON CONFLICT(source_id,kind,local_id) DO UPDATE SET + external_id=excluded.external_id,suppressed_at=excluded.suppressed_at, + reason=excluded.reason,suppressed=excluded.suppressed + `); + const results: Array> = []; + let changedAny = false; + for (const snapshot of snapshots) { + const existing = get<{ + kind: string; + input_revision: string; + revision: string; + generated_at: string | null; + manifest_sha256: string | null; + }>(this.#database, ` + SELECT kind,input_revision,revision,generated_at,manifest_sha256 + FROM corpus_sources WHERE id=? + `, snapshot.source.id); + if (existing !== null && existing.kind !== snapshot.source.kind) { + throw new CliError("conflict", `Source ${snapshot.source.id} changed kind`); + } + if (existing !== null && snapshot.source.kind === "bundle") { + if (existing.generated_at === null || snapshot.source.generatedAt! < existing.generated_at) { + throw new CliError("conflict", `Source ${snapshot.source.id} snapshot is older than stored state`); + } + if ( + snapshot.source.generatedAt === existing.generated_at + && ( + snapshot.source.revision !== existing.input_revision + || snapshot.source.manifestSha256 !== existing.manifest_sha256 + ) + ) throw new CliError("conflict", `Source ${snapshot.source.id} reuses generatedAt for different input`); + } + const authoritative = snapshot.source.kind === "imessage" + || snapshot.source.coverage.history === "complete-current-local"; + if (authoritative) { + for (const row of this.#database.query(` + SELECT conversation_id,external_id FROM conversation_sources WHERE source_id=? + `).iterate(snapshot.source.id) as Iterable<{ + conversation_id: string; + external_id: string; + }>) { + setSuppression.run( + snapshot.source.id, + "conversation", + row.conversation_id, + row.external_id, + ingestedAt, + "authoritative-absence", + 1, + ); + } + for (const row of this.#database.query(` + SELECT id,external_id FROM corpus_reaction_facts WHERE source_id=? + `).iterate(snapshot.source.id) as Iterable<{ id: string; external_id: string }>) { + setSuppression.run( + snapshot.source.id, + "reaction", + row.id, + row.external_id, + ingestedAt, + "authoritative-absence", + 1, + ); + } + for (const row of this.#database.query(` + SELECT provenance.message_id,provenance.external_id,message.kind + FROM message_provenance provenance + JOIN messages message ON message.id=provenance.message_id + WHERE provenance.source_id=? + `).iterate(snapshot.source.id) as Iterable<{ + message_id: string; + external_id: string; + kind: string; + }>) { + setSuppression.run( + snapshot.source.id, + row.kind === "reaction" ? "reaction" : "message", + row.message_id, + row.external_id, + ingestedAt, + "authoritative-absence", + 1, + ); + } + } + upsertSource.run( + snapshot.source.id, + snapshot.source.kind, + snapshot.source.provider, + snapshot.source.network, + snapshot.source.accountId, + snapshot.source.externalId, + snapshot.source.revision, + existing?.revision ?? snapshot.source.revision, + snapshot.source.generatedAt, + canonicalJson(snapshot.source.producer), + canonicalJson(snapshot.source.coverage), + snapshot.source.manifestSha256, + canonicalJson(snapshot.source.identity), + canonicalJson(snapshot.source.warnings), + ingestedAt, + ); + const conversationProvenance = new Map( + snapshot.conversationProvenance.map((value) => [value.conversationId, value]), ); + for (const conversation of snapshot.conversations) { + const owner = get<{ source_id: string }>(this.#database, ` + SELECT source_id FROM conversation_sources WHERE conversation_id=? + `, conversation.id); + if (owner !== null && owner.source_id !== snapshot.source.id) { + throw new CliError("conflict", `Conversation ${conversation.id} belongs to another source`); + } + upsertConversation.run( + conversation.id, + conversation.sourceKey, + conversation.privateLabel, + conversation.service, + conversation.participantCount, + canonicalJson(conversation.participantIds), + canonicalJson(conversation.privateParticipants), + conversation.group ? 1 : 0, + ); + const provenance = conversationProvenance.get(conversation.id)!; + upsertConversationSource.run( + conversation.id, + snapshot.source.id, + provenance.externalId, + canonicalJson(provenance.metadata ?? {}), + ); + setSuppression.run( + snapshot.source.id, + "conversation", + conversation.id, + provenance.externalId, + ingestedAt, + "reappeared", + 0, + ); + } + const messageProvenance = new Map( + snapshot.messageProvenance.map((value) => [value.messageId, value]), + ); + for (const message of snapshot.messages) { + const owner = get<{ source_id: string; source_row_id: number }>(this.#database, ` + SELECT provenance.source_id,message.source_row_id + FROM message_provenance provenance + JOIN messages message ON message.id=provenance.message_id + WHERE provenance.message_id=? + `, message.id); + if (owner !== null && owner.source_id !== snapshot.source.id) { + throw new CliError("conflict", `Message ${message.id} belongs to another source`); + } + const preferredRowId = authoritative ? message.sourceRowId : null; + const preferredCollision = preferredRowId === null ? null : get<{ id: string }>( + this.#database, + "SELECT id FROM messages WHERE conversation_id=? AND source_row_id=?", + message.conversationId, + preferredRowId, + ); + const sourceRowId = owner?.source_row_id ?? ( + preferredRowId !== null && preferredCollision === null + ? preferredRowId + : (get<{ value: number | null }>(this.#database, ` + SELECT max(source_row_id) AS value FROM messages WHERE conversation_id=? + `, message.conversationId)?.value ?? 0) + 1 + ); + upsertMessage.run( + message.id, + sourceRowId, + message.sourceGuid, + message.conversationId, + message.sentAt, + message.direction, + message.body, + message.bodySource, + message.kind, + message.replyToSourceGuid, + message.editedAt, + message.retractedAt, + message.service, + message.attachmentCount, + ); + const provenance = messageProvenance.get(message.id)!; + upsertMessageProvenance.run( + message.id, + snapshot.source.id, + provenance.externalId, + provenance.replyToExternalId, + canonicalJson(provenance.attachments), + canonicalJson(provenance.metadata ?? {}), + ); + setSuppression.run( + snapshot.source.id, + message.kind === "reaction" ? "reaction" : "message", + message.id, + provenance.externalId, + ingestedAt, + "reappeared", + 0, + ); + if (message.kind === "reaction") { + setSuppression.run( + snapshot.source.id, + "reaction-timeline", + message.id, + provenance.externalId, + ingestedAt, + "reappeared", + 0, + ); + } + } + for (const reaction of snapshot.reactionFacts ?? []) { + const existingReaction = get<{ source_id: string; external_id: string }>(this.#database, ` + SELECT source_id,external_id FROM corpus_reaction_facts WHERE id=? + `, reaction.id); + if ( + existingReaction !== null + && ( + existingReaction.source_id !== snapshot.source.id + || existingReaction.external_id !== reaction.externalId + ) + ) throw new CliError("conflict", `Reaction ${reaction.id} belongs to another source coordinate`); + const conversationId = reaction.conversationId ?? get<{ conversation_id: string }>( + this.#database, + `SELECT message.conversation_id + FROM message_provenance provenance + JOIN messages message ON message.id=provenance.message_id + WHERE provenance.source_id=? AND provenance.external_id=?`, + snapshot.source.id, + reaction.targetExternalId, + )?.conversation_id ?? null; + upsertReactionFact.run( + reaction.id, + snapshot.source.id, + reaction.externalId, + reaction.targetExternalId, + conversationId, + reaction.direction, + reaction.body, + reaction.reactedAt, + reaction.state, + ); + if (reaction.state === "active") { + setSuppression.run( + snapshot.source.id, + "reaction", + reaction.id, + reaction.externalId, + ingestedAt, + "reappeared", + 0, + ); + } + } + this.#database.query(` + UPDATE corpus_reaction_facts AS reaction + SET conversation_id=( + SELECT message.conversation_id + FROM message_provenance provenance + JOIN messages message ON message.id=provenance.message_id + WHERE provenance.source_id=reaction.source_id + AND provenance.external_id=reaction.target_external_id + ) + WHERE reaction.source_id=? AND reaction.conversation_id IS NULL + AND EXISTS ( + SELECT 1 FROM message_provenance provenance + WHERE provenance.source_id=reaction.source_id + AND provenance.external_id=reaction.target_external_id + ) + `).run(snapshot.source.id); + for (const record of snapshot.auxiliaryRecords ?? []) { + upsertSourceRecord.run( + snapshot.source.id, + record.kind, + record.id, + canonicalJson(record.record), + ); + } + for (const deletion of snapshot.deletions ?? []) { + let localId = deletion.localEntityId; + if (deletion.entityKind === "conversation") { + const specifiedLocal = localId !== null; + const target = localId === null + ? get<{ conversation_id: string; external_id: string }>(this.#database, ` + SELECT conversation_id,external_id FROM conversation_sources + WHERE source_id=? AND external_id=? + `, snapshot.source.id, deletion.externalId) + : get<{ conversation_id: string; external_id: string }>(this.#database, ` + SELECT conversation_id,external_id FROM conversation_sources + WHERE source_id=? AND conversation_id=? + `, snapshot.source.id, localId); + if (target !== null) { + if (target.external_id !== deletion.externalId) { + throw new CliError("invalid-data", "A conversation deletion has mismatched coordinates"); + } + localId = target.conversation_id; + } else if (specifiedLocal) { + throw new CliError("invalid-data", "A conversation deletion references an unknown local entity"); + } + } + if (deletion.entityKind === "message") { + const specifiedLocal = localId !== null; + const target = localId === null + ? get<{ + message_id: string; + external_id: string; + conversation_id: string; + kind: string; + }>(this.#database, ` + SELECT provenance.message_id,provenance.external_id, + message.conversation_id,message.kind + FROM message_provenance provenance + JOIN messages message ON message.id=provenance.message_id + WHERE provenance.source_id=? AND provenance.external_id=? + `, snapshot.source.id, deletion.externalId) + : get<{ + message_id: string; + external_id: string; + conversation_id: string; + kind: string; + }>(this.#database, ` + SELECT provenance.message_id,provenance.external_id, + message.conversation_id,message.kind + FROM message_provenance provenance + JOIN messages message ON message.id=provenance.message_id + WHERE provenance.source_id=? AND provenance.message_id=? + `, snapshot.source.id, localId); + if (target !== null) { + if ( + target.external_id !== deletion.externalId + || target.kind === "reaction" + || ( + deletion.expectedConversationId !== undefined + && deletion.expectedConversationId !== target.conversation_id + ) + ) throw new CliError("invalid-data", "A message deletion has mismatched coordinates"); + localId = target.message_id; + } else if (specifiedLocal) { + throw new CliError("invalid-data", "A message deletion references an unknown local entity"); + } + } + if ( + deletion.entityKind === "reaction" + || deletion.entityKind === "reaction-timeline" + ) { + const specifiedLocal = localId !== null; + const target = localId === null + ? get<{ + id: string; + external_id: string; + conversation_id: string | null; + }>(this.#database, ` + SELECT id,external_id,conversation_id FROM corpus_reaction_facts + WHERE source_id=? AND external_id=? + `, snapshot.source.id, deletion.externalId) + : get<{ + id: string; + external_id: string; + conversation_id: string | null; + }>(this.#database, ` + SELECT id,external_id,conversation_id FROM corpus_reaction_facts + WHERE source_id=? AND id=? + `, snapshot.source.id, localId); + if (target !== null) { + if ( + target.external_id !== deletion.externalId + || ( + deletion.expectedConversationId !== undefined + && deletion.expectedConversationId !== target.conversation_id + ) + ) throw new CliError("invalid-data", "A reaction deletion has mismatched coordinates"); + localId = target.id; + } else if (specifiedLocal) { + throw new CliError("invalid-data", "A reaction deletion references an unknown local entity"); + } + } + setSuppression.run( + snapshot.source.id, + deletion.entityKind, + localId ?? `external:${deletion.externalId}`, + deletion.externalId, + deletion.deletedAt, + deletion.reason ?? "tombstone", + 1, + ); + } + const stateRevision = sourceStateRevision(this.#database, snapshot.source.id); + this.#database.query("UPDATE corpus_sources SET revision=? WHERE id=?") + .run(stateRevision, snapshot.source.id); + const changed = existing?.revision !== stateRevision; + changedAny ||= changed; + const counts = get<{ conversations: number; messages: number }>(this.#database, ` + SELECT count(distinct conversation.id) AS conversations, + count(message.id) AS messages + FROM conversation_sources ownership + JOIN conversations conversation ON conversation.id=ownership.conversation_id + LEFT JOIN messages message ON message.conversation_id=conversation.id + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=ownership.source_id + AND suppression.local_id=message.id + AND suppression.kind IN ('message','reaction','reaction-timeline') + AND suppression.suppressed=1 + ) + WHERE ownership.source_id=? + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=ownership.source_id + AND suppression.local_id=conversation.id + AND suppression.kind='conversation' + AND suppression.suppressed=1 + ) + `, snapshot.source.id) ?? { conversations: 0, messages: 0 }; + results.push(Object.freeze({ id: snapshot.source.id, changed, ...counts })); } + if (changedAny) rebuildConversationLabels(this.#database, hmacKey); + const corpusRevision = setCorpusRevision(this.#database); + if (corpusRevision === null) throw new CliError("internal", "Source replacement produced no corpus revision"); + return Object.freeze({ corpusRevision, sources: Object.freeze(results) }); + }); + } + + replaceCorpus( + snapshot: CorpusSnapshot, + ingestedAt: string, + hmacKey?: string | Uint8Array, + ): Readonly<{ + corpusRevision: string; + conversations: number; + messages: number; + }> { + if (!/^[a-f0-9]{64}$/u.test(snapshot.source.snapshotSha256)) { + throw new CliError("invalid-data", "The iMessage reader returned an invalid corpus revision"); + } + const observed = snapshot.messages.map(({ sentAt }) => sentAt).sort(); + const sourceSnapshot: SourceCorpusSnapshot = Object.freeze({ + source: Object.freeze({ + id: IMESSAGE_SOURCE_ID, + kind: "imessage", + provider: "apple", + network: null, + accountId: null, + externalId: "local-imessage", + revision: snapshot.source.snapshotSha256, + generatedAt: null, + producer: Object.freeze({ id: "message-like-me", version: "imessage-reader-v1" }), + coverage: Object.freeze({ + history: "complete-current-local", + observedFrom: observed[0] ?? null, + observedTo: observed.at(-1) ?? null, + }), + manifestSha256: null, + identity: snapshot.source, + warnings: snapshot.warnings, + }), + conversations: snapshot.conversations, + conversationProvenance: Object.freeze(snapshot.conversations.map((conversation) => ({ + conversationId: conversation.id, + externalId: conversation.sourceKey, + }))), + messages: snapshot.messages, + messageProvenance: Object.freeze(snapshot.messages.map((message) => ({ + messageId: message.id, + externalId: message.sourceGuid, + replyToExternalId: message.replyToSourceGuid, + attachments: Object.freeze(Array.from({ length: message.attachmentCount }, (_value, index) => ({ + id: `unavailable-${index + 1}`, + kind: null, + mimeType: null, + fileName: null, + bytes: null, + }))), + }))), + }); + const replaced = this.replaceSources([sourceSnapshot], ingestedAt, hmacKey); + transaction(this.#database, () => { const setMetadata = this.#database.query(` - INSERT INTO metadata (key, value) VALUES (?, ?) - ON CONFLICT (key) DO UPDATE SET value = excluded.value + INSERT INTO metadata(key,value) VALUES (?,?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value `); for (const [key, value] of [ - ["corpus_revision", corpusRevision], ["source_identity", canonicalJson(snapshot.source)], ["ingested_at", ingestedAt], ["warnings", canonicalJson(snapshot.warnings)], ["corpus_schema_version", String(snapshot.schemaVersion)], ] as const) setMetadata.run(key, value); - rebuildConversationLabels(this.#database, hmacKey); }); - return { - corpusRevision, + corpusRevision: replaced.corpusRevision, conversations: snapshot.conversations.length, messages: snapshot.messages.length, }; } + listSources(privateDetails = false): ReadonlyArray> { + const rows = all<{ + id: string; + kind: "imessage" | "bundle"; + provider: string; + network: string | null; + account_id: string | null; + external_id: string; + input_revision: string; + revision: string; + generated_at: string | null; + coverage_json: string; + manifest_sha256: string | null; + identity_json: string; + warnings_json: string; + ingested_at: string; + conversations: number; + messages: number; + reactions: number; + undated_reactions: number; + }>(this.#database, ` + SELECT source.*, + count(distinct ownership.conversation_id) AS conversations, + count(message.id) AS messages, + (SELECT count(*) FROM corpus_reaction_facts reaction + WHERE reaction.source_id=source.id AND reaction.state='active' + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id AND suppression.kind='reaction' + AND suppression.local_id=reaction.id AND suppression.suppressed=1 + )) AS reactions, + (SELECT count(*) FROM corpus_reaction_facts reaction + WHERE reaction.source_id=source.id AND reaction.state='active' + AND reaction.reacted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id AND suppression.kind='reaction' + AND suppression.local_id=reaction.id AND suppression.suppressed=1 + )) AS undated_reactions + FROM corpus_sources source + LEFT JOIN conversation_sources ownership ON ownership.source_id=source.id + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id + AND suppression.kind='conversation' + AND suppression.local_id=ownership.conversation_id + AND suppression.suppressed=1 + ) + LEFT JOIN messages message ON message.conversation_id=ownership.conversation_id + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id + AND suppression.kind IN ('message','reaction','reaction-timeline') + AND suppression.local_id=message.id + AND suppression.suppressed=1 + ) + GROUP BY source.id + ORDER BY source.provider,source.network,source.id + `); + return rows.map((row) => { + const warnings = parsedJson(row.warnings_json, `Source ${row.id} warnings`); + if (!Array.isArray(warnings)) throw new CliError("invalid-data", `Source ${row.id} warnings are invalid`); + return { + id: row.id, + kind: row.kind, + provider: row.provider, + network: row.network, + revision: row.revision, + generatedAt: row.generated_at, + ingestedAt: row.ingested_at, + coverage: parsedJson(row.coverage_json, `Source ${row.id} coverage`), + warningCount: warnings.length, + conversations: row.conversations, + messages: row.messages, + reactions: row.reactions, + undatedReactions: row.undated_reactions, + ...(privateDetails ? { + accountId: row.account_id, + externalId: row.external_id, + manifestSha256: row.manifest_sha256, + inputRevision: row.input_revision, + identity: parsedJson(row.identity_json, `Source ${row.id} identity`), + warnings, + } : {}), + }; + }); + } + + source(sourceId: string, privateDetails = false): ReturnType[number] | null { + if (sourceId.length < 1 || sourceId.length > 256) { + throw new CliError("usage", "Source ID must be bounded non-empty text"); + } + return this.listSources(privateDetails).find(({ id }) => id === sourceId) ?? null; + } + listContacts(options: Readonly<{ privateLabels: boolean; minimumOutgoing: number; @@ -1145,16 +2371,30 @@ export class LocalStore { conversation.id AS conversation_id FROM conversation_contact_scopes association JOIN conversations conversation ON conversation.id=association.conversation_id + JOIN conversation_sources ownership ON ownership.conversation_id=conversation.id LEFT JOIN conversation_contact_labels label ON label.conversation_id=association.conversation_id - WHERE conversation.is_group=0 + WHERE conversation.is_group=0 AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=ownership.source_id + AND suppression.kind='conversation' + AND suppression.local_id=conversation.id + AND suppression.suppressed=1 + ) UNION ALL SELECT conversation.id,conversation.private_label,'conversation',conversation.is_group, conversation.participant_count,conversation.id FROM conversations conversation + JOIN conversation_sources ownership ON ownership.conversation_id=conversation.id LEFT JOIN conversation_contact_scopes association ON association.conversation_id=conversation.id - WHERE association.conversation_id IS NULL + WHERE association.conversation_id IS NULL AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=ownership.source_id + AND suppression.kind='conversation' + AND suppression.local_id=conversation.id + AND suppression.suppressed=1 + ) ) SELECT scope.id,min(scope.private_label) AS private_label, max(scope.scope_kind) AS scope_kind, @@ -1166,6 +2406,14 @@ export class LocalStore { sum(CASE WHEN message.direction = 'outgoing' THEN 1 ELSE 0 END) AS outgoing_count FROM scope_conversations scope JOIN messages message ON message.conversation_id=scope.conversation_id + JOIN message_provenance provenance ON provenance.message_id=message.id + WHERE NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=provenance.source_id + AND suppression.local_id=message.id + AND suppression.kind IN ('message','reaction','reaction-timeline') + AND suppression.suppressed=1 + ) GROUP BY scope.id HAVING outgoing_count >= ? ORDER BY outgoing_count DESC,last_message_at DESC,scope.id @@ -1224,6 +2472,7 @@ export class LocalStore { messageCount: number; incomingCount: number; outgoingCount: number; + services: readonly string[]; }) | null { const scope = analysisScope(this.#database, contactId); if (scope === null) return null; @@ -1286,6 +2535,8 @@ export class LocalStore { scopeKind: scope.kind, conversationCount: scope.conversationIds.length, service: services.length === 1 ? services[0]! : null, + services: Object.freeze(services.sort((left, right) => + left < right ? -1 : left > right ? 1 : 0)), participantCount: scope.kind === "person" ? 1 : first.participant_count, participantIds: participants, privateParticipants, @@ -1306,6 +2557,7 @@ export class LocalStore { corpusRevision: string; evidenceRevision: string; messages: CorpusMessage[]; + reactions: CorpusReactionFact[]; }> | null { const window = evidenceWindow(options, "Evidence window"); return readTransaction(this.#database, () => { @@ -1321,6 +2573,7 @@ export class LocalStore { corpusRevision, evidenceRevision: scopeEvidenceRevision(this.#database, scope, undefined, window), messages: messageRowsForScope(this.#database, scope, undefined, window).map(corpusMessage), + reactions: reactionFactsForScope(this.#database, scope, window), }; }); } @@ -1556,6 +2809,7 @@ export class LocalStore { foreignKeyViolations: number; corpusRevision: string | null; contactsRevision: string | null; + sources: number; conversations: number; messages: number; profiles: number; @@ -1564,7 +2818,7 @@ export class LocalStore { }> { const quick = get<{ quick_check: string }>(this.#database, "PRAGMA quick_check")?.quick_check ?? "unknown"; const foreignKeys = all(this.#database, "PRAGMA foreign_key_check").length; - const count = (table: "conversations" | "messages" | "profiles" | "addressbook_contacts" | "conversation_contact_labels") => + const count = (table: "corpus_sources" | "conversations" | "messages" | "profiles" | "addressbook_contacts" | "conversation_contact_labels") => get<{ value: number }>(this.#database, `SELECT count(*) AS value FROM ${table}`)?.value ?? 0; return { storeSchemaVersion: userVersion(this.#database), @@ -1572,6 +2826,7 @@ export class LocalStore { foreignKeyViolations: foreignKeys, corpusRevision: this.corpusRevision(), contactsRevision: this.contactsRevision(), + sources: count("corpus_sources"), conversations: count("conversations"), messages: count("messages"), profiles: count("profiles"), diff --git a/src/test-bundle-fixture.ts b/src/test-bundle-fixture.ts new file mode 100644 index 0000000..679bcb0 --- /dev/null +++ b/src/test-bundle-fixture.ts @@ -0,0 +1,228 @@ +import { chmod, mkdir, realpath, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { canonicalJson, sha256 } from "./canonical-json.ts"; + +export const BUNDLE_ARTIFACTS = Object.freeze([ + Object.freeze({ path: "accounts.ndjson", kind: "account" as const }), + Object.freeze({ path: "participants.ndjson", kind: "participant" as const }), + Object.freeze({ path: "conversations.ndjson", kind: "conversation" as const }), + Object.freeze({ path: "messages.ndjson", kind: "message" as const }), + Object.freeze({ path: "reactions.ndjson", kind: "reaction" as const }), + Object.freeze({ path: "tombstones.ndjson", kind: "tombstone" as const }), +]); + +export type BundleArtifactKind = typeof BUNDLE_ARTIFACTS[number]["kind"]; +export type SyntheticBundleRecords = Record>>; + +const CONNECTED_ACCOUNT = "synthetic-connected-account"; +const OBSERVED_AT = "2026-08-20T12:05:00.000Z"; + +function provenance(providerId: string): Record { + return { + providerId, + providerRevision: null, + observedAt: OBSERVED_AT, + connectedAccountProviderId: CONNECTED_ACCOUNT, + }; +} + +function common(kind: BundleArtifactKind, id: string, providerId: string): Record { + return { + schemaVersion: 1, + kind, + id, + accountId: "account-local", + network: "whatsapp", + provenance: provenance(providerId), + }; +} + +export function syntheticBundleRecords(): SyntheticBundleRecords { + return { + account: [{ + ...common("account", "account-local", CONNECTED_ACCOUNT), + displayName: "Synthetic Account", + handle: null, + selfParticipantId: "participant-self", + }], + participant: [ + { + ...common("participant", "participant-self", "participant-provider-self"), + displayName: "Synthetic Self", + handle: "+15555550100", + isSelf: true, + }, + { + ...common("participant", "participant-peer", "participant-provider-peer"), + displayName: "Synthetic Peer", + handle: "peer@example.test", + isSelf: false, + }, + ], + conversation: [{ + ...common("conversation", "conversation-local", "conversation-provider-1"), + type: "direct", + title: "Synthetic Direct", + participantIds: ["participant-self", "participant-peer"], + participantsComplete: true, + startedAt: "2026-08-20T12:00:00.000Z", + lastMessageAt: "2026-08-20T12:04:00.000Z", + }], + message: [ + { + ...common("message", "message-incoming", "message-provider-incoming"), + conversationId: "conversation-local", + senderParticipantId: "participant-peer", + direction: "incoming", + sentAt: "2026-08-20T12:00:00.000Z", + sortKey: "0001", + body: "Synthetic question?", + bodyTruncated: false, + replyTo: null, + edit: null, + deletion: null, + attachments: [], + }, + { + ...common("message", "message-outgoing", "message-provider-outgoing"), + conversationId: "conversation-local", + senderParticipantId: "participant-self", + direction: "outgoing", + sentAt: "2026-08-20T12:01:00.000Z", + sortKey: "0002", + body: "Synthetic answer.", + bodyTruncated: false, + replyTo: { messageId: "message-incoming", providerId: "message-provider-incoming" }, + edit: null, + deletion: null, + attachments: [{ + kind: "image", + mimeType: "image/png", + name: "synthetic.png", + sizeBytes: 1234, + }], + }, + { + ...common("message", "message-truncated", "message-provider-truncated"), + conversationId: "conversation-local", + senderParticipantId: "participant-self", + direction: "outgoing", + sentAt: "2026-08-20T12:02:00.000Z", + sortKey: "0003", + body: "Synthetic partial body", + bodyTruncated: true, + replyTo: null, + edit: null, + deletion: null, + attachments: [], + }, + { + ...common("message", "message-deleted", "message-provider-deleted"), + conversationId: "conversation-local", + senderParticipantId: "participant-peer", + direction: "incoming", + sentAt: "2026-08-20T12:03:00.000Z", + sortKey: "0004", + body: null, + bodyTruncated: null, + replyTo: null, + edit: null, + deletion: { + state: "revoked", + observedAt: "2026-08-20T12:04:00.000Z", + providerRevision: null, + }, + attachments: [], + }, + ], + reaction: [ + { + ...common("reaction", "reaction-dated", "reaction-provider-dated"), + messageId: "message-outgoing", + messageProviderId: "message-provider-outgoing", + participantId: "participant-peer", + body: "heart", + reactedAt: "2026-08-20T12:01:30.000Z", + state: "active", + }, + { + ...common("reaction", "reaction-undated", "reaction-provider-undated"), + messageId: "message-incoming", + messageProviderId: "message-provider-incoming", + participantId: "participant-self", + body: "thumbs-up", + reactedAt: null, + state: "active", + }, + ], + tombstone: [], + }; +} + +export async function writeSyntheticMessageBundle( + parent: string, + records: SyntheticBundleRecords = syntheticBundleRecords(), + options: Readonly<{ + directoryName?: string; + completenessKind?: "bounded-local" | "truncated" | "unknown"; + completenessReason?: string | null; + createdAt?: string; + }> = {}, +): Promise { + const directory = join(parent, options.directoryName ?? "synthetic-message-bundle"); + await mkdir(directory, { mode: 0o700 }); + await chmod(directory, 0o700); + const artifacts: Array> = []; + const counts: Record = {}; + for (const artifact of BUNDLE_ARTIFACTS) { + const values = records[artifact.kind]; + const bytes = Buffer.from(values.map((value) => `${canonicalJson(value)}\n`).join(""), "utf8"); + const path = join(directory, artifact.path); + await writeFile(path, bytes, { mode: 0o600 }); + await chmod(path, 0o600); + counts[artifact.kind] = values.length; + artifacts.push({ + path: artifact.path, + mediaType: "application/x-ndjson", + recordKind: artifact.kind, + records: values.length, + bytes: bytes.byteLength, + sha256: sha256(bytes), + }); + } + const projection = { + schemaVersion: 1, + format: "message-like-me.local-message-bundle", + source: { id: "beeper-local", version: "0.1.0-test" }, + provider: { id: "beeper", version: "1.2.3-test" }, + timestamps: { + startedAt: "2026-08-20T12:00:00.000Z", + finishedAt: "2026-08-20T12:05:00.000Z", + createdAt: options.createdAt ?? "2026-08-20T12:05:01.000Z", + }, + completeness: { + kind: options.completenessKind ?? "truncated", + reason: options.completenessReason === undefined ? "synthetic-limit" : options.completenessReason, + observedFrom: "2026-08-20T12:00:00.000Z", + observedThrough: "2026-08-20T12:04:00.000Z", + }, + warnings: ["synthetic-fixture"], + privacy: { + classification: "private-local", + attachments: "metadata-only", + providerUrls: "excluded", + credentials: "excluded", + }, + counts, + artifacts, + }; + const manifest = { + ...projection, + integrity: { algorithm: "sha256", bundleSha256: sha256(canonicalJson(projection)) }, + }; + const path = join(directory, "manifest.json"); + await writeFile(path, `${canonicalJson(manifest)}\n`, { mode: 0o600 }); + await chmod(path, 0o600); + return realpath(directory); +} diff --git a/src/types.ts b/src/types.ts index e667f98..4deb843 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,10 +1,11 @@ export const CORPUS_SCHEMA_VERSION = 1 as const; -export const METRICS_SCHEMA_VERSION = 1 as const; +export const METRICS_SCHEMA_VERSION = 2 as const; export const PROFILE_SCHEMA_VERSION = 2 as const; export const LEGACY_PROFILE_SCHEMA_VERSION = 1 as const; export const STUDY_PACKET_SCHEMA_VERSION = 2 as const; export const EVALUATION_PACKET_SCHEMA_VERSION = 1 as const; export const CONTACTS_SCHEMA_VERSION = 1 as const; +export const MESSAGE_BUNDLE_SCHEMA_VERSION = 1 as const; export type Direction = "incoming" | "outgoing"; export type BodySource = "text" | "attributed-body" | "unavailable"; @@ -61,6 +62,102 @@ export type CorpusSnapshot = Readonly<{ warnings: readonly string[]; }>; +export type CorpusSourceKind = "imessage" | "bundle"; + +export type CorpusSourceCoverage = Readonly<{ + history: "complete-current-local" | "bounded" | "unknown"; + observedFrom: string | null; + observedTo: string | null; + /** Producer-specific completeness classification, when the import format has one. */ + kind?: string; + /** Producer-supplied categorical reason for incomplete coverage. */ + reason?: string | null; +}>; + +export type CorpusSourceDescriptor = Readonly<{ + /** Per-install source pseudonym used by the local store and CLI. */ + id: string; + kind: CorpusSourceKind; + provider: string; + network: string | null; + /** Private provider account identifier. Ordinary source views omit it. */ + accountId: string | null; + /** Private producer-local source identifier. Ordinary source views omit it. */ + externalId: string; + revision: string; + generatedAt: string | null; + producer: Readonly<{ id: string; version: string }>; + coverage: CorpusSourceCoverage; + manifestSha256: string | null; + identity: unknown; + warnings: readonly string[]; +}>; + +export type CorpusConversationProvenance = Readonly<{ + conversationId: string; + externalId: string; + metadata?: unknown; +}>; + +export type CorpusAttachmentProvenance = Readonly<{ + id: string; + kind: string | null; + mimeType: string | null; + fileName: string | null; + bytes: number | null; +}>; + +export type CorpusMessageProvenance = Readonly<{ + messageId: string; + externalId: string; + replyToExternalId: string | null; + attachments: readonly CorpusAttachmentProvenance[]; + metadata?: unknown; +}>; + +export type CorpusReactionFact = Readonly<{ + id: string; + externalId: string; + targetExternalId: string; + conversationId: string | null; + direction: Direction | null; + body: string; + reactedAt: string | null; + state: "active" | "removed"; +}>; + +export type CorpusSourceRecord = Readonly<{ + kind: "account" | "participant" | "reaction" | "tombstone" | "excluded-message"; + id: string; + record: unknown; +}>; + +export type CorpusSourceDeletion = Readonly<{ + entityKind: "account" | "participant" | "conversation" | "message" | "reaction" | "reaction-timeline"; + localEntityId: string | null; + externalId: string; + deletedAt: string; + expectedConversationId?: string; + reason?: "tombstone" | "explicit-exclusion" | "replacement"; +}>; + +export type SourceCorpusSnapshot = Readonly<{ + source: CorpusSourceDescriptor; + conversations: readonly CorpusConversation[]; + conversationProvenance: readonly CorpusConversationProvenance[]; + messages: readonly CorpusMessage[]; + messageProvenance: readonly CorpusMessageProvenance[]; + reactionFacts?: readonly CorpusReactionFact[]; + auxiliaryRecords?: readonly CorpusSourceRecord[]; + deletions?: readonly CorpusSourceDeletion[]; +}>; + +export type MessageBundleSnapshot = Readonly<{ + schemaVersion: typeof MESSAGE_BUNDLE_SCHEMA_VERSION; + manifestSha256: string; + sources: readonly SourceCorpusSnapshot[]; +}>; + export type ContactHandle = Readonly<{ kind: "email" | "phone"; normalizedValue: string; @@ -160,7 +257,17 @@ export type ReactionMetrics = Readonly<{ total: number; incoming: number; outgoing: number; + unknownDirection: number; + dated: number; + undated: number; outgoingReactionRatio: number; + byBody: readonly Readonly<{ + body: string; + total: number; + incoming: number; + outgoing: number; + unknownDirection: number; + }>[]; }>; export type ContactMetrics = Readonly<{ diff --git a/src/version.ts b/src/version.ts index 61356c6..cef521b 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const MESSAGE_LIKE_ME_VERSION = "0.2.0" as const; +export const MESSAGE_LIKE_ME_VERSION = "0.3.0" as const; From afe181349f13c0484f3a0b94f66ef4e72b47e55c Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 22 Aug 2026 15:29:37 -0400 Subject: [PATCH 2/7] test: align sequential Beeper bundle fixture --- src/bundle.test.ts | 67 ++++++++++++++----- .../beeper-message-like-me-v1/accounts.ndjson | 1 + .../beeper-message-like-me-v1/manifest.json | 2 +- .../participants.ndjson | 1 + 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/bundle.test.ts b/src/bundle.test.ts index f4e0861..685467c 100644 --- a/src/bundle.test.ts +++ b/src/bundle.test.ts @@ -13,7 +13,7 @@ import { } from "./test-bundle-fixture.ts"; const TEST_KEY = "synthetic-bundle-test-key-32-bytes"; -const GOLDEN_MANIFEST_SHA256 = "e46f4a524d53f849cfac594fb5bc8cf28e7a9743c138039b81a0aad4ff4830ef"; +const GOLDEN_MANIFEST_SHA256 = "dcef93293af9af0f3b0ff303992517ce2eece6d4bf0b7477e30c0b9d77a2c7f1"; const GOLDEN_FILES = Object.freeze([ "accounts.ndjson", "participants.ndjson", @@ -74,46 +74,68 @@ describe("private local message bundle", () => { const path = await materializeWrenchGoldenBundle(root); const bundle = await readMessageBundle(path, { hmacKey: TEST_KEY }); expect(bundle.manifestSha256).toBe(GOLDEN_MANIFEST_SHA256); - expect(bundle.sources).toHaveLength(1); - expect(bundle.sources[0]!.source).toMatchObject({ + expect(bundle.sources).toHaveLength(2); + const primary = bundle.sources.find(({ source }) => source.network === "synthetic"); + const secondary = bundle.sources.find(({ source }) => + source.network === "synthetic-secondary"); + expect(primary).toBeDefined(); + expect(secondary).toBeDefined(); + expect(primary!.source).toMatchObject({ provider: "beeper", network: "synthetic", - producer: { id: "beeper-local", version: "1.0.0" }, + producer: { id: "beeper-local", version: "1.1.0" }, coverage: { - kind: "truncated", - reason: "explicit-source-limit", + kind: "bounded-local", + reason: "desktop-local-sequential-export", }, }); - expect(bundle.sources[0]!.messages[0]).toMatchObject({ + expect(primary!.source.warnings).toContain("sequential-account-snapshot"); + expect(secondary!.source).toMatchObject({ + provider: "beeper", + network: "synthetic-secondary", + producer: { id: "beeper-local", version: "1.1.0" }, + coverage: { + kind: "bounded-local", + reason: "desktop-local-sequential-export", + observedFrom: null, + observedTo: null, + }, + }); + expect(secondary!.source.warnings).toContain("sequential-account-snapshot"); + expect(secondary!.conversations).toHaveLength(0); + expect(secondary!.messages).toHaveLength(0); + expect(primary!.messages[0]).toMatchObject({ body: "edited synthetic reply", editedAt: "2026-08-21T15:58:30.000Z", retractedAt: null, replyToSourceGuid: "beeper-message:synthetic-external-reply-target", }); - expect(bundle.sources[0]!.messages[1]).toMatchObject({ + expect(primary!.messages[1]).toMatchObject({ body: null, retractedAt: "2026-08-21T15:59:00.000Z", direction: "incoming", }); - expect(bundle.sources[0]!.reactionFacts).toMatchObject([{ + expect(primary!.reactionFacts).toMatchObject([{ body: "👍", reactedAt: null, direction: "incoming", }]); - expect(bundle.sources[0]!.deletions).toEqual(expect.arrayContaining([ + expect(primary!.deletions).toEqual(expect.arrayContaining([ expect.objectContaining({ entityKind: "message", externalId: "beeper-message:synthetic-deleted", deletedAt: "2026-08-21T15:59:00.000Z", }), ])); - expect(bundle.sources[0]!.deletions?.some(({ externalId }) => + expect(primary!.deletions?.some(({ externalId }) => externalId === "beeper-message:synthetic-edited")).toBeFalse(); const store = LocalStore.open(join(root, "golden-store.sqlite3")); try { store.replaceSources(bundle.sources, "2026-08-21T16:01:00.000Z", TEST_KEY); - expect(store.listSources()).toMatchObject([{ + const storedSources = store.listSources(); + expect(storedSources).toHaveLength(2); + expect(storedSources.find(({ network }) => network === "synthetic")).toMatchObject({ provider: "beeper", network: "synthetic", conversations: 1, @@ -121,10 +143,25 @@ describe("private local message bundle", () => { reactions: 1, undatedReactions: 1, coverage: { - kind: "truncated", - reason: "explicit-source-limit", + kind: "bounded-local", + reason: "desktop-local-sequential-export", }, - }]); + }); + expect(storedSources.find(({ network }) => network === "synthetic-secondary")) + .toMatchObject({ + provider: "beeper", + network: "synthetic-secondary", + conversations: 0, + messages: 0, + reactions: 0, + undatedReactions: 0, + coverage: { + kind: "bounded-local", + reason: "desktop-local-sequential-export", + observedFrom: null, + observedTo: null, + }, + }); } finally { store.close(); } diff --git a/src/fixtures/beeper-message-like-me-v1/accounts.ndjson b/src/fixtures/beeper-message-like-me-v1/accounts.ndjson index d0d5e48..2c91aff 100644 --- a/src/fixtures/beeper-message-like-me-v1/accounts.ndjson +++ b/src/fixtures/beeper-message-like-me-v1/accounts.ndjson @@ -1 +1,2 @@ {"accountId":"account:synthetic:primary","displayName":"Synthetic Primary","handle":"+15555550100","id":"account:synthetic:primary","kind":"account","network":"synthetic","provenance":{"connectedAccountProviderId":"beeper-account:synthetic-primary","observedAt":"2026-08-21T15:59:00.000Z","providerId":"beeper-account:synthetic-primary","providerRevision":null},"schemaVersion":1,"selfParticipantId":"participant:synthetic:self"} +{"accountId":"account:synthetic:secondary","displayName":"Synthetic Secondary","handle":"synthetic-secondary@example.invalid","id":"account:synthetic:secondary","kind":"account","network":"synthetic-secondary","provenance":{"connectedAccountProviderId":"beeper-account:synthetic-secondary","observedAt":"2026-08-21T15:59:00.000Z","providerId":"beeper-account:synthetic-secondary","providerRevision":null},"schemaVersion":1,"selfParticipantId":"participant:synthetic:secondary-self"} diff --git a/src/fixtures/beeper-message-like-me-v1/manifest.json b/src/fixtures/beeper-message-like-me-v1/manifest.json index 0aca85e..0d9b70e 100644 --- a/src/fixtures/beeper-message-like-me-v1/manifest.json +++ b/src/fixtures/beeper-message-like-me-v1/manifest.json @@ -1 +1 @@ -{"artifacts":[{"bytes":430,"mediaType":"application/x-ndjson","path":"accounts.ndjson","recordKind":"account","records":1,"sha256":"5e29e5ca806fc4a97c22e63d0061c722ce0f62f14e96383e70c15eaff57ee1f8"},{"bytes":797,"mediaType":"application/x-ndjson","path":"participants.ndjson","recordKind":"participant","records":2,"sha256":"a7fb621e298c45f481a3d2f3995cfcbea80693f52c7787c1ff05b69248134186"},{"bytes":571,"mediaType":"application/x-ndjson","path":"conversations.ndjson","recordKind":"conversation","records":1,"sha256":"87957914b3f21ece69815719db37ea6413efdf3201b68bd60a0cf7abbbacb529"},{"bytes":1492,"mediaType":"application/x-ndjson","path":"messages.ndjson","recordKind":"message","records":2,"sha256":"dbe97f5b4a5c46c6c2f3e02294511ceb7868e45a8738f8736c0ec93d6707c394"},{"bytes":521,"mediaType":"application/x-ndjson","path":"reactions.ndjson","recordKind":"reaction","records":1,"sha256":"683c263ce93e82bca0d905bcf6ef4cec6b32d8028aaa52595d0f295f068a6b01"},{"bytes":539,"mediaType":"application/x-ndjson","path":"tombstones.ndjson","recordKind":"tombstone","records":1,"sha256":"34f5a61aa9756c10979372b6147337053a563a6a7c63cd44eab602e2594e05d5"}],"completeness":{"kind":"truncated","observedFrom":"2026-08-21T15:50:00.000Z","observedThrough":"2026-08-21T15:59:00.000Z","reason":"explicit-source-limit"},"counts":{"account":1,"conversation":1,"message":2,"participant":2,"reaction":1,"tombstone":1},"format":"message-like-me.local-message-bundle","integrity":{"algorithm":"sha256","bundleSha256":"56c6ff3bbe60acfd103f234592087a9753d4b90447d0a508575e0e7c5d4cf514"},"privacy":{"attachments":"metadata-only","classification":"private-local","credentials":"excluded","providerUrls":"excluded"},"provider":{"id":"beeper","version":"0.6.2"},"schemaVersion":1,"source":{"id":"beeper-local","version":"1.0.0"},"timestamps":{"createdAt":"2026-08-21T16:00:01.000Z","finishedAt":"2026-08-21T16:00:01.000Z","startedAt":"2026-08-21T16:00:00.000Z"},"warnings":["attachments-metadata-only","remote-history-not-claimed","synthetic-golden-fixture"]} +{"artifacts":[{"bytes":913,"mediaType":"application/x-ndjson","path":"accounts.ndjson","recordKind":"account","records":2,"sha256":"822cb760142762378a27aa32d35aa2f8422c353cf139700bdd754454c9516db6"},{"bytes":1262,"mediaType":"application/x-ndjson","path":"participants.ndjson","recordKind":"participant","records":3,"sha256":"1287cb91bcce7e5e6299115a86e4c18f57bb6801b07424e95652974a1ad9d87f"},{"bytes":571,"mediaType":"application/x-ndjson","path":"conversations.ndjson","recordKind":"conversation","records":1,"sha256":"87957914b3f21ece69815719db37ea6413efdf3201b68bd60a0cf7abbbacb529"},{"bytes":1492,"mediaType":"application/x-ndjson","path":"messages.ndjson","recordKind":"message","records":2,"sha256":"dbe97f5b4a5c46c6c2f3e02294511ceb7868e45a8738f8736c0ec93d6707c394"},{"bytes":521,"mediaType":"application/x-ndjson","path":"reactions.ndjson","recordKind":"reaction","records":1,"sha256":"683c263ce93e82bca0d905bcf6ef4cec6b32d8028aaa52595d0f295f068a6b01"},{"bytes":539,"mediaType":"application/x-ndjson","path":"tombstones.ndjson","recordKind":"tombstone","records":1,"sha256":"34f5a61aa9756c10979372b6147337053a563a6a7c63cd44eab602e2594e05d5"}],"completeness":{"kind":"bounded-local","observedFrom":"2026-08-21T15:50:00.000Z","observedThrough":"2026-08-21T15:59:00.000Z","reason":"desktop-local-sequential-export"},"counts":{"account":2,"conversation":1,"message":2,"participant":3,"reaction":1,"tombstone":1},"format":"message-like-me.local-message-bundle","integrity":{"algorithm":"sha256","bundleSha256":"f4c1c8f99e9ff74a4e8ba300a7e0e417f6e2cadb21a90dc3e712e3f50b518c1d"},"privacy":{"attachments":"metadata-only","classification":"private-local","credentials":"excluded","providerUrls":"excluded"},"provider":{"id":"beeper","version":"0.6.2"},"schemaVersion":1,"source":{"id":"beeper-local","version":"1.1.0"},"timestamps":{"createdAt":"2026-08-21T16:00:01.000Z","finishedAt":"2026-08-21T16:00:01.000Z","startedAt":"2026-08-21T16:00:00.000Z"},"warnings":["attachments-metadata-only","connected-account-backfill-coverage-unknown","remote-history-not-claimed","sequential-account-snapshot","synthetic-golden-fixture"]} diff --git a/src/fixtures/beeper-message-like-me-v1/participants.ndjson b/src/fixtures/beeper-message-like-me-v1/participants.ndjson index 6a867b6..9bf8961 100644 --- a/src/fixtures/beeper-message-like-me-v1/participants.ndjson +++ b/src/fixtures/beeper-message-like-me-v1/participants.ndjson @@ -1,2 +1,3 @@ {"accountId":"account:synthetic:primary","displayName":"Synthetic Self","handle":"+15555550100","id":"participant:synthetic:self","isSelf":true,"kind":"participant","network":"synthetic","provenance":{"connectedAccountProviderId":"beeper-account:synthetic-primary","observedAt":"2026-08-21T15:59:00.000Z","providerId":"beeper-participant:synthetic-self","providerRevision":null},"schemaVersion":1} {"accountId":"account:synthetic:primary","displayName":"Synthetic Peer","handle":"+15555550101","id":"participant:synthetic:peer","isSelf":false,"kind":"participant","network":"synthetic","provenance":{"connectedAccountProviderId":"beeper-account:synthetic-primary","observedAt":"2026-08-21T15:59:00.000Z","providerId":"beeper-participant:synthetic-peer","providerRevision":null},"schemaVersion":1} +{"accountId":"account:synthetic:secondary","displayName":"Synthetic Secondary Self","handle":"synthetic-secondary@example.invalid","id":"participant:synthetic:secondary-self","isSelf":true,"kind":"participant","network":"synthetic-secondary","provenance":{"connectedAccountProviderId":"beeper-account:synthetic-secondary","observedAt":"2026-08-21T15:59:00.000Z","providerId":"beeper-participant:synthetic-secondary-self","providerRevision":null},"schemaVersion":1} From 0c3b048c2995e3ecc2c7a186174348acbc3501bf Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 22 Aug 2026 17:43:13 -0400 Subject: [PATCH 3/7] docs: explain transparent sequential Beeper export --- CHANGELOG.md | 2 ++ README.md | 19 ++++++++++++++----- site/app/readme.generated.ts | 2 +- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 747410f..1770729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ terminal state suppresses evidence, and later reappearance restores it. - Add `sources list` and `sources show` with active message, conversation, reaction, undated-reaction, completeness, and warning health. +- Document Wrench's direct official Beeper CLI path, sequential per-account + progress, retained-shard validation, and atomic seven-file publication. - Partition sessions, bursts, and response episodes by conversation, preserve truncated text bubbles as tempo evidence, and count undated reactions without inventing timestamps. diff --git a/README.md b/README.md index 6e3f587..0152e20 100644 --- a/README.md +++ b/README.md @@ -93,11 +93,20 @@ wrench beeper export-message-like-me \ The optional `--limit-chats`, `--limit-messages`, and `--max-participants` flags lower the export bounds. The output path must be a normalized absolute -path to a directory that does not already exist. Wrench uses the pinned local -Beeper CLI export without attachment bytes, writes a mode-`0700` directory -with mode-`0600` files, and writes `manifest.json` last. Provider URLs and -credentials are excluded. Message Like Me does not receive the Beeper -credential and does not call Beeper or Wrench itself. +path to a directory that does not already exist. Wrench calls the pinned +[official Beeper CLI](https://github.com/beeper/cli) directly. It enumerates +the connected account realm, invokes `export --no-attachments` once per +account in deterministic order, and reports the account ordinal, elapsed-time +heartbeats, and cumulative validated chat and message counts on stderr. It +retains each private raw shard until it can atomically publish the complete +mode-`0700` seven-file bundle with mode-`0600` files. + +The export does not use the separate +[Beeper Desktop API MCP project](https://github.com/beeper/desktop-api-mcp). +The CLI path supplies the bounded account snapshots and local files needed for +hash validation, deterministic conversion, crash recovery, and atomic +publication. Provider URLs and credentials are excluded. Message Like Me does +not receive the Beeper credential and does not call Beeper or Wrench itself. Ingest the finished directory, then inspect its redacted source health: diff --git a/site/app/readme.generated.ts b/site/app/readme.generated.ts index caa07cd..17b5b16 100644 --- a/site/app/readme.generated.ts +++ b/site/app/readme.generated.ts @@ -1,2 +1,2 @@ // Generated from ../README.md by scripts/sync-readme.ts. -export const readmeHtml = "

Message Like Me

\n

A local-first CLI and Agent Skill for studying private messaging history and\ndrafting messages that sound like you.

\n

Message Like Me turns private local messaging history into deterministic\nconversation metrics, bounded study packets, and reusable style profiles. It\nreads native iMessage history and strict local source bundles, including\nmulti-account Beeper exports produced through Wrench. Its Agent Skill teaches\nCodex, Claude, and other coding agents how to interpret those local artifacts\nand draft unsent replies in your voice.

\n

The CLI does not call an AI service, authenticate with a product account, send\nmessages, or operate Messages. The agent already running the skill supplies the\nsemantic analysis and drafting judgment.

\n

This is an evidence layer for relationship-aware drafting, not a digital clone.\nIt does not train a model, represent your identity, infer your beliefs, or claim\nthat a draft is what you would have written. Your current meaning, facts, and\nintent outrank historical style.

\n

Install

\n

Message Like Me requires Bun 1.3.14 or newer. Install the immutable public\nrelease from GitHub, then install the Agent Skill:

\n
bun add --global github:hraness/message-like-me#v0.3.0\nmessagelikeme skill install\n
\n

Start a new agent session after installing the skill. The default target is\nCodex at user scope. Other supported targets and project-local installation are\navailable explicitly:

\n
messagelikeme skill install --target claude\nmessagelikeme skill install --target agents --scope project\nmessagelikeme skill path\n
\n

Message Like Me is distributed directly through GitHub and is not published to\nnpm.

\n

Start with private local history

\n

Initialize the private data store and inspect its location:

\n
messagelikeme init\nmessagelikeme doctor --json\n
\n

On macOS, the default store is:

\n
~/Library/Application Support/Message Like Me/\n
\n

The directory is private to the current user. It contains a local SQLite\ndatabase, stored profiles, and a private installation key used to derive\nstable pseudonymous IDs. Study packets are written only to the explicit path\nyou choose. You can put the store elsewhere by placing\n--data-dir /absolute/private/path before the command.

\n

Import the current user's iMessage database:

\n
messagelikeme ingest imessage --json\n
\n

The default source is the current user's Messages chat.db. Use --database\nonly to name another caller-owned physical database:

\n
messagelikeme ingest imessage --database /absolute/path/to/chat.db --json\n
\n

Ingestion validates the source schema and ownership, makes a stable private\ncopy of the database and its transactional sidecars, and opens only that copy\nwith SQLite. It does not change Messages, chat.db, or its sidecars. macOS may\nrequire permission for the terminal or agent host to read Messages data.

\n

To study accounts connected through Beeper, first ask Wrench to create a new\nprivate Message Like Me bundle:

\n
wrench beeper export-message-like-me \\\n  --auth <beeper-auth-id> \\\n  --output /absolute/private/path/beeper-bundle \\\n  --json\n
\n

The optional --limit-chats, --limit-messages, and --max-participants\nflags lower the export bounds. The output path must be a normalized absolute\npath to a directory that does not already exist. Wrench uses the pinned local\nBeeper CLI export without attachment bytes, writes a mode-0700 directory\nwith mode-0600 files, and writes manifest.json last. Provider URLs and\ncredentials are excluded. Message Like Me does not receive the Beeper\ncredential and does not call Beeper or Wrench itself.

\n

Ingest the finished directory, then inspect its redacted source health:

\n
messagelikeme ingest bundle --input /absolute/private/path/beeper-bundle --json\nmessagelikeme sources list --json\nmessagelikeme sources show <source-id> --json\n
\n

The importer verifies the fixed version-one inventory, canonical UTF-8 NDJSON,\nrecord and byte bounds, owner-only permissions, artifact digests, and manifest\ndigest before changing the store. One bundle may contain several connected\naccounts and networks; each becomes a separate source namespace. Native\niMessage and prior bundle sources remain alongside it.

\n

The complete interchange, integrity, identity, and reimport laws are in the\nversion-one local message bundle contract.

\n

Beeper exports describe bounded local observations. A later bounded export\nthat omits an older record does not delete retained history. Explicit deletion,\nremoval, replacement, and tombstone records suppress their target, and a later\nreappearance restores it. Older snapshots cannot overwrite newer state. Use\nsources show <source-id> --private --json only when you deliberately need the\nprivate provider account and source metadata.

\n

Optionally enrich and join direct conversations with private identities from\nmacOS Contacts:

\n
messagelikeme ingest contacts --json\n
\n

The default source is the current user's AddressBook directory. An explicit\nabsolute AddressBook root, Sources directory, store directory, or\nAddressBook-vN.abcddb file can be selected with --addressbook:

\n
messagelikeme ingest contacts \\\n  --addressbook /absolute/path/to/AddressBook \\\n  --json\n
\n

Contacts ingest may run before or after any message source. It reads only\nbounded name, email, and phone fields from a stable private copy. Exact\nnormalized email or E.164 phone handles can join several one-to-one threads\nfor the same AddressBook person into one analysis scope. A bundle conversation\nis eligible only when the producer positively marks its direct participant\nroster complete. Existing conversation IDs remain aliases for that person\nscope. Shared handles remain ambiguous, local phone numbers never gain a\nguessed country code, unmatched threads stay separate, and groups are never\ncollapsed to one person. Contact labels have their own revision, so a rename\ndoes not stale a messaging-style profile. messagelikeme doctor reports local\naggregate state without asking for an account or credential.

\n

Inspect behavior without exposing prose

\n

Contact listings and aggregate views omit private labels, handles, and message\nbodies by default:

\n
messagelikeme contacts list --min-outgoing 20 --json\nmessagelikeme contacts show <contact-id> --json\nmessagelikeme inspect tempo <contact-id> --session-gap 28800 --burst-gap 300 --json\nmessagelikeme inspect sessions <contact-id> --limit 20 --json\n
\n

The metrics cover conversation start and end, message counts, incoming and\noutgoing turns, within-session response latency, single-message versus\nmulti-message replies, surface prose features, multi-point response contexts,\nreactions, and explicit reply use. Incoming messages establish what you were\nresponding to; they are never counted as examples of your writing style.\nSessions, bursts, and response episodes never cross a source conversation\nboundary. Person scopes spanning several apps expose a sorted services\nbreakdown instead of hiding the mixed-channel evidence behind a null service.\nReactions with no provider timestamp still contribute to reaction counts and\ndirection, but never to temporal metrics. Session and burst gaps are\nconfigurable seconds and are recorded with each result. They are segmentation\nchoices, not universal facts about conversation.

\n

Pass --private to contacts list or contacts show only when you need to\nresolve a pseudonymous contact to its local private label or participants.

\n

When you already know the complete Contacts label, resolve only that exact\nprivate name instead of listing every label:

\n
messagelikeme contacts resolve "Exact Contact Name" --private --json\n
\n

Resolution is normalized for case and Unicode representation, but it does not\nperform prefix, substring, phonetic, or fuzzy matching. It returns only direct\nperson scopes and labels, never handles or message bodies.

\n

Build a style profile

\n

Aggregate metrics cannot explain why a short burst works in one context or why\na longer single message appears in another. For that semantic work, prepare a\nsmall, diverse study packet at an explicit private path:

\n
messagelikeme study prepare <contact-id> \\\n  --output /absolute/private/path/study.json \\\n  --before 2026-08-01T00:00:00.000Z \\\n  --limit 24 \\\n  --json\n
\n

study prepare and evaluate prepare are the only commands that write bounded\nmessage bodies outside the private database. Their outputs are mode 0600.\nA study packet contains incoming context and outgoing responses selected across\ndifferent response shapes; it is not a full transcript export. By default,\neach body is capped at 4 KiB, each example keeps at most 12 text messages per\ndirection, and the entire packet keeps at most 256 KiB of body text. Packet\ncoverage fields report every truncation or omission explicitly.

\n

Keep the JSON receipt with the analysis. Its packetSha256 binds the finished\nprofile to these exact packet bytes; the packet does not contain its own digest.

\n

--after is inclusive and --before is exclusive. Temporal bounds let you\nreserve later conversations for evaluation. Invoke $message-like-me in your\nagent and ask it to analyze that contact. The skill separates measured facts\nfrom inferred patterns, covers prose and tempo, studies how several inbound\npoints are handled, and treats reply links and tapbacks separately from written\ntext.

\n

The agent writes a schema-version-two profile and asks the CLI to validate and\nstore it:

\n
messagelikeme profile apply /absolute/private/path/profile.json --json\nmessagelikeme profile show <contact-id> --json\n
\n

A version-two profile records the global corpus revision for provenance, a\nperson-and-window-specific evidence revision for validity, the exact\nstudy-packet SHA-256, and the packet's non-body evidence manifest. Measured and\ninferred claims cite valid packet example IDs and record counterexamples,\nsupport counts, confidence, and drafting consequences. Messages for someone\nelse or outside the studied time window do not stale it; changes inside its\nactual evidence do.

\n

Export a profile only when you need an explicit private copy:

\n
messagelikeme profile export <contact-id> --output /absolute/private/path/profile.json\n
\n

Version-one profiles remain readable for migration, but new analyses should use\nschema/style-profile-v2.schema.json.

\n

Audit against later conversations

\n

Prepare a separate prompt and reference set from conversations after the study\ncutoff:

\n
messagelikeme evaluate prepare <contact-id> \\\n  --after 2026-08-01T00:00:00.000Z \\\n  --prompt-output /absolute/private/path/evaluation-prompts.json \\\n  --reference-output /absolute/private/path/evaluation-references.json \\\n  --json\n
\n

Give the agent only the prompt file and fix one candidate bubble sequence per\ncase before opening the reference file. Then compare intent coverage, factual\nmeaning, prose, bubble shape, explicit replies, privacy leakage, and\ncalibration. The files support a blind workflow but do not enforce one, and the\nhistorical response is one observation rather than a unique correct answer.\nThe CLI deliberately does not collapse these dimensions into a universal\nfidelity score. See the methodology.

\n

Draft an unsent reply

\n

Ask an agent with the installed $message-like-me skill to draft for a\npseudonymous contact. The compact deterministic context is available through:

\n
messagelikeme context <contact-id> --json\n
\n

The skill preserves your intended meaning, selects the applicable profile,\nand can express the result as one message or a realistic sequence of separate\nbubbles. It uses explicit replies only when your evidence and the current\ncontext support them.

\n

Drafting ends with text in the agent task. Message Like Me has no send, react,\nschedule, or messaging-application command.

\n

Command reference

\n

Run messagelikeme --help for the checked grammar. The public surfaces are:

\n
messagelikeme init [--json]\nmessagelikeme ingest imessage [--database PATH] [--json]\nmessagelikeme ingest contacts [--addressbook PATH] [--json]\nmessagelikeme ingest bundle --input ABS_PATH [--json]\nmessagelikeme sources list [--private] [--json]\nmessagelikeme sources show SOURCE_ID [--private] [--json]\nmessagelikeme contacts list [--min-outgoing N] [--limit N] [--private] [--json]\nmessagelikeme contacts show CONTACT_ID [--private] [--json]\nmessagelikeme contacts resolve QUERY --private [--limit N] [--json]\nmessagelikeme inspect tempo CONTACT_ID [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme inspect sessions CONTACT_ID [--limit N] [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme study prepare CONTACT_ID --output FILE [--limit N]\n  [--after ISO_TIMESTAMP] [--before ISO_TIMESTAMP]\n  [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme evaluate prepare CONTACT_ID --after ISO_TIMESTAMP\n  --prompt-output FILE --reference-output FILE [--before ISO_TIMESTAMP]\n  [--limit N] [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme profile apply FILE [--json]\nmessagelikeme profile show CONTACT_ID [--json]\nmessagelikeme profile export CONTACT_ID --output FILE [--json]\nmessagelikeme context CONTACT_ID [--json]\nmessagelikeme skill path [--json]\nmessagelikeme skill install [--target codex|claude|agents]\n  [--scope user|project] [--project PATH] [--force] [--json]\nmessagelikeme doctor [--json]\n
\n

Place global --data-dir PATH before the command.

\n

Privacy model

\n
    \n
  • The original chat.db and AddressBook databases remain authoritative.\nSQLite opens only stable private copies, never the source files or sidecars.
  • \n
  • Source bundles remain private caller-owned inputs. Import verifies their\nfixed inventory, canonical bytes, digests, bounds, and owner-only modes.
  • \n
  • The normalized corpus, profiles, and installation key stay in a private local\nstore with owner-only permissions.
  • \n
  • Stable source, contact, participant, conversation, message, and reaction IDs\nare derived with a private per-install HMAC key. Pseudonymous IDs are not\nencryption.
  • \n
  • Aggregate commands omit bodies and private labels. Study and evaluation\npackets are bounded, explicit body-bearing exports.
  • \n
  • Message text never goes to a Message Like Me server. There is no service,\naccount, auth flow, analytics client, or network-backed model call.
  • \n
  • Opening a study packet makes its bounded excerpts visible to the agent\nenvironment already running the skill. Use an agent environment whose data\nhandling you accept; the CLI cannot make a hosted agent local.
  • \n
  • Public fixtures are synthetic. Private corpora, profiles, packets, and drafts\ndo not belong in Git, issues, logs, packages, or examples.
  • \n
  • A draft is never sent.
  • \n
\n

Read SECURITY.md before integrating the library into another\ntool or handling a private packet outside the CLI. The\nmethodology defines every unit and evidence boundary;\nthe research review documents papers, neighboring OSS, and\nthe claims this project does not make.

\n

TypeScript library

\n

The package exports the versioned corpus, metrics, study-packet, and profile\ntypes plus deterministic canonical JSON and SHA-256 helpers:

\n
import type { ContactMetrics, StyleProfileV2 } from "@hraness/message-like-me"\nimport { canonicalJson, sha256 } from "@hraness/message-like-me"\n
\n

The library does not start the CLI, inspect Messages or Contacts, connect to a\nnetwork, or send a draft merely because it is imported.

\n

Development

\n
bun install --frozen-lockfile --ignore-scripts\nbun run check\n
\n

Tests use synthetic Messages and AddressBook databases plus synthetic source\nbundles and conversations. Never add a real message, handle, group title,\nattachment, contact record, private path, or derived profile to a fixture.

\n

The canonical repository is\nhraness/message-like-me.\nThe informational project page is\nmessagelikeme.com. The CLI does not connect to\nthe site, and the site never receives message or contact data.

\n

License

\n

MIT.

\n"; +export const readmeHtml = "

Message Like Me

\n

A local-first CLI and Agent Skill for studying private messaging history and\ndrafting messages that sound like you.

\n

Message Like Me turns private local messaging history into deterministic\nconversation metrics, bounded study packets, and reusable style profiles. It\nreads native iMessage history and strict local source bundles, including\nmulti-account Beeper exports produced through Wrench. Its Agent Skill teaches\nCodex, Claude, and other coding agents how to interpret those local artifacts\nand draft unsent replies in your voice.

\n

The CLI does not call an AI service, authenticate with a product account, send\nmessages, or operate Messages. The agent already running the skill supplies the\nsemantic analysis and drafting judgment.

\n

This is an evidence layer for relationship-aware drafting, not a digital clone.\nIt does not train a model, represent your identity, infer your beliefs, or claim\nthat a draft is what you would have written. Your current meaning, facts, and\nintent outrank historical style.

\n

Install

\n

Message Like Me requires Bun 1.3.14 or newer. Install the immutable public\nrelease from GitHub, then install the Agent Skill:

\n
bun add --global github:hraness/message-like-me#v0.3.0\nmessagelikeme skill install\n
\n

Start a new agent session after installing the skill. The default target is\nCodex at user scope. Other supported targets and project-local installation are\navailable explicitly:

\n
messagelikeme skill install --target claude\nmessagelikeme skill install --target agents --scope project\nmessagelikeme skill path\n
\n

Message Like Me is distributed directly through GitHub and is not published to\nnpm.

\n

Start with private local history

\n

Initialize the private data store and inspect its location:

\n
messagelikeme init\nmessagelikeme doctor --json\n
\n

On macOS, the default store is:

\n
~/Library/Application Support/Message Like Me/\n
\n

The directory is private to the current user. It contains a local SQLite\ndatabase, stored profiles, and a private installation key used to derive\nstable pseudonymous IDs. Study packets are written only to the explicit path\nyou choose. You can put the store elsewhere by placing\n--data-dir /absolute/private/path before the command.

\n

Import the current user's iMessage database:

\n
messagelikeme ingest imessage --json\n
\n

The default source is the current user's Messages chat.db. Use --database\nonly to name another caller-owned physical database:

\n
messagelikeme ingest imessage --database /absolute/path/to/chat.db --json\n
\n

Ingestion validates the source schema and ownership, makes a stable private\ncopy of the database and its transactional sidecars, and opens only that copy\nwith SQLite. It does not change Messages, chat.db, or its sidecars. macOS may\nrequire permission for the terminal or agent host to read Messages data.

\n

To study accounts connected through Beeper, first ask Wrench to create a new\nprivate Message Like Me bundle:

\n
wrench beeper export-message-like-me \\\n  --auth <beeper-auth-id> \\\n  --output /absolute/private/path/beeper-bundle \\\n  --json\n
\n

The optional --limit-chats, --limit-messages, and --max-participants\nflags lower the export bounds. The output path must be a normalized absolute\npath to a directory that does not already exist. Wrench calls the pinned\nofficial Beeper CLI directly. It enumerates\nthe connected account realm, invokes export --no-attachments once per\naccount in deterministic order, and reports the account ordinal, elapsed-time\nheartbeats, and cumulative validated chat and message counts on stderr. It\nretains each private raw shard until it can atomically publish the complete\nmode-0700 seven-file bundle with mode-0600 files.

\n

The export does not use the separate\nBeeper Desktop API MCP project.\nThe CLI path supplies the bounded account snapshots and local files needed for\nhash validation, deterministic conversion, crash recovery, and atomic\npublication. Provider URLs and credentials are excluded. Message Like Me does\nnot receive the Beeper credential and does not call Beeper or Wrench itself.

\n

Ingest the finished directory, then inspect its redacted source health:

\n
messagelikeme ingest bundle --input /absolute/private/path/beeper-bundle --json\nmessagelikeme sources list --json\nmessagelikeme sources show <source-id> --json\n
\n

The importer verifies the fixed version-one inventory, canonical UTF-8 NDJSON,\nrecord and byte bounds, owner-only permissions, artifact digests, and manifest\ndigest before changing the store. One bundle may contain several connected\naccounts and networks; each becomes a separate source namespace. Native\niMessage and prior bundle sources remain alongside it.

\n

The complete interchange, integrity, identity, and reimport laws are in the\nversion-one local message bundle contract.

\n

Beeper exports describe bounded local observations. A later bounded export\nthat omits an older record does not delete retained history. Explicit deletion,\nremoval, replacement, and tombstone records suppress their target, and a later\nreappearance restores it. Older snapshots cannot overwrite newer state. Use\nsources show <source-id> --private --json only when you deliberately need the\nprivate provider account and source metadata.

\n

Optionally enrich and join direct conversations with private identities from\nmacOS Contacts:

\n
messagelikeme ingest contacts --json\n
\n

The default source is the current user's AddressBook directory. An explicit\nabsolute AddressBook root, Sources directory, store directory, or\nAddressBook-vN.abcddb file can be selected with --addressbook:

\n
messagelikeme ingest contacts \\\n  --addressbook /absolute/path/to/AddressBook \\\n  --json\n
\n

Contacts ingest may run before or after any message source. It reads only\nbounded name, email, and phone fields from a stable private copy. Exact\nnormalized email or E.164 phone handles can join several one-to-one threads\nfor the same AddressBook person into one analysis scope. A bundle conversation\nis eligible only when the producer positively marks its direct participant\nroster complete. Existing conversation IDs remain aliases for that person\nscope. Shared handles remain ambiguous, local phone numbers never gain a\nguessed country code, unmatched threads stay separate, and groups are never\ncollapsed to one person. Contact labels have their own revision, so a rename\ndoes not stale a messaging-style profile. messagelikeme doctor reports local\naggregate state without asking for an account or credential.

\n

Inspect behavior without exposing prose

\n

Contact listings and aggregate views omit private labels, handles, and message\nbodies by default:

\n
messagelikeme contacts list --min-outgoing 20 --json\nmessagelikeme contacts show <contact-id> --json\nmessagelikeme inspect tempo <contact-id> --session-gap 28800 --burst-gap 300 --json\nmessagelikeme inspect sessions <contact-id> --limit 20 --json\n
\n

The metrics cover conversation start and end, message counts, incoming and\noutgoing turns, within-session response latency, single-message versus\nmulti-message replies, surface prose features, multi-point response contexts,\nreactions, and explicit reply use. Incoming messages establish what you were\nresponding to; they are never counted as examples of your writing style.\nSessions, bursts, and response episodes never cross a source conversation\nboundary. Person scopes spanning several apps expose a sorted services\nbreakdown instead of hiding the mixed-channel evidence behind a null service.\nReactions with no provider timestamp still contribute to reaction counts and\ndirection, but never to temporal metrics. Session and burst gaps are\nconfigurable seconds and are recorded with each result. They are segmentation\nchoices, not universal facts about conversation.

\n

Pass --private to contacts list or contacts show only when you need to\nresolve a pseudonymous contact to its local private label or participants.

\n

When you already know the complete Contacts label, resolve only that exact\nprivate name instead of listing every label:

\n
messagelikeme contacts resolve "Exact Contact Name" --private --json\n
\n

Resolution is normalized for case and Unicode representation, but it does not\nperform prefix, substring, phonetic, or fuzzy matching. It returns only direct\nperson scopes and labels, never handles or message bodies.

\n

Build a style profile

\n

Aggregate metrics cannot explain why a short burst works in one context or why\na longer single message appears in another. For that semantic work, prepare a\nsmall, diverse study packet at an explicit private path:

\n
messagelikeme study prepare <contact-id> \\\n  --output /absolute/private/path/study.json \\\n  --before 2026-08-01T00:00:00.000Z \\\n  --limit 24 \\\n  --json\n
\n

study prepare and evaluate prepare are the only commands that write bounded\nmessage bodies outside the private database. Their outputs are mode 0600.\nA study packet contains incoming context and outgoing responses selected across\ndifferent response shapes; it is not a full transcript export. By default,\neach body is capped at 4 KiB, each example keeps at most 12 text messages per\ndirection, and the entire packet keeps at most 256 KiB of body text. Packet\ncoverage fields report every truncation or omission explicitly.

\n

Keep the JSON receipt with the analysis. Its packetSha256 binds the finished\nprofile to these exact packet bytes; the packet does not contain its own digest.

\n

--after is inclusive and --before is exclusive. Temporal bounds let you\nreserve later conversations for evaluation. Invoke $message-like-me in your\nagent and ask it to analyze that contact. The skill separates measured facts\nfrom inferred patterns, covers prose and tempo, studies how several inbound\npoints are handled, and treats reply links and tapbacks separately from written\ntext.

\n

The agent writes a schema-version-two profile and asks the CLI to validate and\nstore it:

\n
messagelikeme profile apply /absolute/private/path/profile.json --json\nmessagelikeme profile show <contact-id> --json\n
\n

A version-two profile records the global corpus revision for provenance, a\nperson-and-window-specific evidence revision for validity, the exact\nstudy-packet SHA-256, and the packet's non-body evidence manifest. Measured and\ninferred claims cite valid packet example IDs and record counterexamples,\nsupport counts, confidence, and drafting consequences. Messages for someone\nelse or outside the studied time window do not stale it; changes inside its\nactual evidence do.

\n

Export a profile only when you need an explicit private copy:

\n
messagelikeme profile export <contact-id> --output /absolute/private/path/profile.json\n
\n

Version-one profiles remain readable for migration, but new analyses should use\nschema/style-profile-v2.schema.json.

\n

Audit against later conversations

\n

Prepare a separate prompt and reference set from conversations after the study\ncutoff:

\n
messagelikeme evaluate prepare <contact-id> \\\n  --after 2026-08-01T00:00:00.000Z \\\n  --prompt-output /absolute/private/path/evaluation-prompts.json \\\n  --reference-output /absolute/private/path/evaluation-references.json \\\n  --json\n
\n

Give the agent only the prompt file and fix one candidate bubble sequence per\ncase before opening the reference file. Then compare intent coverage, factual\nmeaning, prose, bubble shape, explicit replies, privacy leakage, and\ncalibration. The files support a blind workflow but do not enforce one, and the\nhistorical response is one observation rather than a unique correct answer.\nThe CLI deliberately does not collapse these dimensions into a universal\nfidelity score. See the methodology.

\n

Draft an unsent reply

\n

Ask an agent with the installed $message-like-me skill to draft for a\npseudonymous contact. The compact deterministic context is available through:

\n
messagelikeme context <contact-id> --json\n
\n

The skill preserves your intended meaning, selects the applicable profile,\nand can express the result as one message or a realistic sequence of separate\nbubbles. It uses explicit replies only when your evidence and the current\ncontext support them.

\n

Drafting ends with text in the agent task. Message Like Me has no send, react,\nschedule, or messaging-application command.

\n

Command reference

\n

Run messagelikeme --help for the checked grammar. The public surfaces are:

\n
messagelikeme init [--json]\nmessagelikeme ingest imessage [--database PATH] [--json]\nmessagelikeme ingest contacts [--addressbook PATH] [--json]\nmessagelikeme ingest bundle --input ABS_PATH [--json]\nmessagelikeme sources list [--private] [--json]\nmessagelikeme sources show SOURCE_ID [--private] [--json]\nmessagelikeme contacts list [--min-outgoing N] [--limit N] [--private] [--json]\nmessagelikeme contacts show CONTACT_ID [--private] [--json]\nmessagelikeme contacts resolve QUERY --private [--limit N] [--json]\nmessagelikeme inspect tempo CONTACT_ID [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme inspect sessions CONTACT_ID [--limit N] [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme study prepare CONTACT_ID --output FILE [--limit N]\n  [--after ISO_TIMESTAMP] [--before ISO_TIMESTAMP]\n  [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme evaluate prepare CONTACT_ID --after ISO_TIMESTAMP\n  --prompt-output FILE --reference-output FILE [--before ISO_TIMESTAMP]\n  [--limit N] [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme profile apply FILE [--json]\nmessagelikeme profile show CONTACT_ID [--json]\nmessagelikeme profile export CONTACT_ID --output FILE [--json]\nmessagelikeme context CONTACT_ID [--json]\nmessagelikeme skill path [--json]\nmessagelikeme skill install [--target codex|claude|agents]\n  [--scope user|project] [--project PATH] [--force] [--json]\nmessagelikeme doctor [--json]\n
\n

Place global --data-dir PATH before the command.

\n

Privacy model

\n
    \n
  • The original chat.db and AddressBook databases remain authoritative.\nSQLite opens only stable private copies, never the source files or sidecars.
  • \n
  • Source bundles remain private caller-owned inputs. Import verifies their\nfixed inventory, canonical bytes, digests, bounds, and owner-only modes.
  • \n
  • The normalized corpus, profiles, and installation key stay in a private local\nstore with owner-only permissions.
  • \n
  • Stable source, contact, participant, conversation, message, and reaction IDs\nare derived with a private per-install HMAC key. Pseudonymous IDs are not\nencryption.
  • \n
  • Aggregate commands omit bodies and private labels. Study and evaluation\npackets are bounded, explicit body-bearing exports.
  • \n
  • Message text never goes to a Message Like Me server. There is no service,\naccount, auth flow, analytics client, or network-backed model call.
  • \n
  • Opening a study packet makes its bounded excerpts visible to the agent\nenvironment already running the skill. Use an agent environment whose data\nhandling you accept; the CLI cannot make a hosted agent local.
  • \n
  • Public fixtures are synthetic. Private corpora, profiles, packets, and drafts\ndo not belong in Git, issues, logs, packages, or examples.
  • \n
  • A draft is never sent.
  • \n
\n

Read SECURITY.md before integrating the library into another\ntool or handling a private packet outside the CLI. The\nmethodology defines every unit and evidence boundary;\nthe research review documents papers, neighboring OSS, and\nthe claims this project does not make.

\n

TypeScript library

\n

The package exports the versioned corpus, metrics, study-packet, and profile\ntypes plus deterministic canonical JSON and SHA-256 helpers:

\n
import type { ContactMetrics, StyleProfileV2 } from "@hraness/message-like-me"\nimport { canonicalJson, sha256 } from "@hraness/message-like-me"\n
\n

The library does not start the CLI, inspect Messages or Contacts, connect to a\nnetwork, or send a draft merely because it is imported.

\n

Development

\n
bun install --frozen-lockfile --ignore-scripts\nbun run check\n
\n

Tests use synthetic Messages and AddressBook databases plus synthetic source\nbundles and conversations. Never add a real message, handle, group title,\nattachment, contact record, private path, or derived profile to a fixture.

\n

The canonical repository is\nhraness/message-like-me.\nThe informational project page is\nmessagelikeme.com. The CLI does not connect to\nthe site, and the site never receives message or contact data.

\n

License

\n

MIT.

\n"; From 1cccf4de6c98a259480457b74d2d822730aeb5db Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 22 Aug 2026 17:54:51 -0400 Subject: [PATCH 4/7] fix: align local bundle contract validation --- dist/cli.js | 9 +- docs/local-message-bundle-v1.md | 1 + schema/local-message-bundle-v1.schema.json | 26 ++++-- scripts/local-message-bundle-schema.test.ts | 20 ++++- src/bundle.test.ts | 91 +++++++++++++++++++++ src/bundle.ts | 10 ++- 6 files changed, 143 insertions(+), 14 deletions(-) diff --git a/dist/cli.js b/dist/cli.js index cc071ad..a7b84ab 100755 --- a/dist/cli.js +++ b/dist/cli.js @@ -870,6 +870,9 @@ function identifier(value, label) { } return result; } +function nullableIdentifier(value, label) { + return value === null ? null : identifier(value, label); +} function token(value, label, maximum = 128) { const result = boundedText2(value, label, maximum); if (!/^[a-z0-9](?:[a-z0-9._+-]*[a-z0-9])?$/u.test(result)) { @@ -941,7 +944,7 @@ function parseProvenance(value, label) { exactKeys(record, ["providerId", "providerRevision", "observedAt", "connectedAccountProviderId"], label); return Object.freeze({ providerId: identifier(record.providerId, `${label}.providerId`), - providerRevision: nullableText(record.providerRevision, `${label}.providerRevision`, MAX_IDENTIFIER_BYTES2), + providerRevision: nullableIdentifier(record.providerRevision, `${label}.providerRevision`), observedAt: timestamp(record.observedAt, `${label}.observedAt`), connectedAccountProviderId: identifier(record.connectedAccountProviderId, `${label}.connectedAccountProviderId`) }); @@ -1064,7 +1067,7 @@ function parseDeletion(value, label) { return Object.freeze({ state: oneOf(record.state, ["revoked", "deleted-for-me", "revoked-and-deleted-for-me"], `${label}.state`), observedAt: timestamp(record.observedAt, `${label}.observedAt`), - providerRevision: nullableText(record.providerRevision, `${label}.providerRevision`, MAX_IDENTIFIER_BYTES2) + providerRevision: nullableIdentifier(record.providerRevision, `${label}.providerRevision`) }); } function parseAttachments(value, label) { @@ -1157,7 +1160,7 @@ function parseTombstone(record, label) { entityProviderId: identifier(record.entityProviderId, `${label}.entityProviderId`), deletedAt: timestamp(record.deletedAt, `${label}.deletedAt`), scope: oneOf(record.scope, ["remote", "local", "unknown"], `${label}.scope`), - providerRevision: nullableText(record.providerRevision, `${label}.providerRevision`, MAX_IDENTIFIER_BYTES2) + providerRevision: nullableIdentifier(record.providerRevision, `${label}.providerRevision`) }); } function parseRecord(value, kind, label) { diff --git a/docs/local-message-bundle-v1.md b/docs/local-message-bundle-v1.md index 9218898..3b93d0b 100644 --- a/docs/local-message-bundle-v1.md +++ b/docs/local-message-bundle-v1.md @@ -61,6 +61,7 @@ Version one has these hard importer and producer ceilings: - 1,024 UTF-8 bytes for an identifier, sort key, or provider revision; - 8 KiB of UTF-8 for a display name, handle, title, reaction body, or attachment filename; +- 256 UTF-8 bytes for an attachment MIME type; - 10,000 known participants in one conversation; - 256 attachment metadata items in one message; and - 128 unique categorical warning codes. diff --git a/schema/local-message-bundle-v1.schema.json b/schema/local-message-bundle-v1.schema.json index 34ee2ea..f46a189 100644 --- a/schema/local-message-bundle-v1.schema.json +++ b/schema/local-message-bundle-v1.schema.json @@ -26,6 +26,12 @@ "pattern": "^[^\\u0000]*$", "description": "Runtime bound is 8 KiB UTF-8." }, + "mimeType": { + "type": "string", + "maxLength": 256, + "pattern": "^[^\\u0000]*$", + "description": "Runtime bound is 256 UTF-8 bytes." + }, "body": { "type": "string", "maxLength": 1048576, @@ -69,6 +75,12 @@ { "$ref": "#/$defs/shortText" } ] }, + "nullableMimeType": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/mimeType" } + ] + }, "nullableTimestamp": { "oneOf": [ { "type": "null" }, @@ -162,7 +174,7 @@ "required": ["kind", "mimeType", "name", "sizeBytes"], "properties": { "kind": { "enum": ["audio", "document", "image", "link", "sticker", "video", "unknown"] }, - "mimeType": { "$ref": "#/$defs/nullableShortText" }, + "mimeType": { "$ref": "#/$defs/nullableMimeType" }, "name": { "$ref": "#/$defs/nullableShortText" }, "sizeBytes": { "oneOf": [ @@ -411,12 +423,12 @@ "minItems": 6, "maxItems": 6, "prefixItems": [ - { "allOf": [{ "$ref": "#/$defs/artifact" }, { "properties": { "path": { "const": "accounts.ndjson" }, "recordKind": { "const": "account" } } }] }, - { "allOf": [{ "$ref": "#/$defs/artifact" }, { "properties": { "path": { "const": "participants.ndjson" }, "recordKind": { "const": "participant" } } }] }, - { "allOf": [{ "$ref": "#/$defs/artifact" }, { "properties": { "path": { "const": "conversations.ndjson" }, "recordKind": { "const": "conversation" } } }] }, - { "allOf": [{ "$ref": "#/$defs/artifact" }, { "properties": { "path": { "const": "messages.ndjson" }, "recordKind": { "const": "message" } } }] }, - { "allOf": [{ "$ref": "#/$defs/artifact" }, { "properties": { "path": { "const": "reactions.ndjson" }, "recordKind": { "const": "reaction" } } }] }, - { "allOf": [{ "$ref": "#/$defs/artifact" }, { "properties": { "path": { "const": "tombstones.ndjson" }, "recordKind": { "const": "tombstone" } } }] } + { "allOf": [{ "$ref": "#/$defs/artifact" }, { "type": "object", "properties": { "path": { "const": "accounts.ndjson" }, "recordKind": { "const": "account" } } }] }, + { "allOf": [{ "$ref": "#/$defs/artifact" }, { "type": "object", "properties": { "path": { "const": "participants.ndjson" }, "recordKind": { "const": "participant" } } }] }, + { "allOf": [{ "$ref": "#/$defs/artifact" }, { "type": "object", "properties": { "path": { "const": "conversations.ndjson" }, "recordKind": { "const": "conversation" } } }] }, + { "allOf": [{ "$ref": "#/$defs/artifact" }, { "type": "object", "properties": { "path": { "const": "messages.ndjson" }, "recordKind": { "const": "message" } } }] }, + { "allOf": [{ "$ref": "#/$defs/artifact" }, { "type": "object", "properties": { "path": { "const": "reactions.ndjson" }, "recordKind": { "const": "reaction" } } }] }, + { "allOf": [{ "$ref": "#/$defs/artifact" }, { "type": "object", "properties": { "path": { "const": "tombstones.ndjson" }, "recordKind": { "const": "tombstone" } } }] } ] }, "integrity": { diff --git a/scripts/local-message-bundle-schema.test.ts b/scripts/local-message-bundle-schema.test.ts index 3c39256..35bfe15 100644 --- a/scripts/local-message-bundle-schema.test.ts +++ b/scripts/local-message-bundle-schema.test.ts @@ -37,7 +37,25 @@ test("local message bundle schema publishes the frozen v1 contract", async () => const artifacts = object(manifestProperties.artifacts, "manifest.artifacts"); expect(artifacts.minItems).toBe(6); expect(artifacts.maxItems).toBe(6); - expect(array(artifacts.prefixItems, "manifest.artifacts.prefixItems")).toHaveLength(6); + const artifactPrefixes = array(artifacts.prefixItems, "manifest.artifacts.prefixItems"); + expect(artifactPrefixes).toHaveLength(6); + for (const [index, prefixValue] of artifactPrefixes.entries()) { + const prefix = object(prefixValue, `manifest.artifacts.prefixItems[${index}]`); + const refinement = object( + array(prefix.allOf, `manifest.artifacts.prefixItems[${index}].allOf`)[1], + `manifest.artifacts.prefixItems[${index}].allOf[1]`, + ); + expect(refinement.type).toBe("object"); + } + + const mimeType = object(definitions.mimeType, "mimeType"); + expect(mimeType.maxLength).toBe(256); + expect(mimeType.pattern).toBe("^[^\\u0000]*$"); + expect(mimeType.description).toBe("Runtime bound is 256 UTF-8 bytes."); + const attachment = object(definitions.attachment, "attachment"); + const attachmentProperties = object(attachment.properties, "attachment.properties"); + expect(object(attachmentProperties.mimeType, "attachment.mimeType").$ref) + .toBe("#/$defs/nullableMimeType"); const message = object(definitions.message, "message"); const messageProperties = object(message.properties, "message.properties"); diff --git a/src/bundle.test.ts b/src/bundle.test.ts index 685467c..7fa694d 100644 --- a/src/bundle.test.ts +++ b/src/bundle.test.ts @@ -247,6 +247,97 @@ describe("private local message bundle", () => { } }); + test("requires every nullable provider revision to be an identifier", async () => { + const locations: Array, + value: string, + ) => void; + }>> = [ + { + mutate: (records, value) => { + const provenance = records.account[0]!.provenance as Record; + provenance.providerRevision = value; + }, + }, + { + mutate: (records, value) => { + const deletion = records.message[3]!.deletion as Record; + deletion.providerRevision = value; + }, + }, + { + mutate: (records, value) => { + records.tombstone.push({ + schemaVersion: 1, + kind: "tombstone", + id: "tombstone-local", + accountId: "account-local", + network: "whatsapp", + provenance: { + providerId: "tombstone-provider", + providerRevision: null, + observedAt: "2026-08-20T12:05:00.000Z", + connectedAccountProviderId: "synthetic-connected-account", + }, + entityKind: "message", + entityId: "message-incoming", + entityProviderId: "message-provider-incoming", + deletedAt: "2026-08-20T12:05:00.000Z", + scope: "remote", + providerRevision: value, + }); + }, + }, + ]; + const invalidValues = ["", "\t", "\n"]; + for (const [locationIndex, location] of locations.entries()) { + for (const [valueIndex, value] of invalidValues.entries()) { + const root = await mkdtemp(join( + tmpdir(), + `message-like-me-bundle-revision-${locationIndex}-${valueIndex}-`, + )); + try { + const records = syntheticBundleRecords(); + location.mutate(records, value); + const path = await writeSyntheticMessageBundle(root, records); + await expect(readMessageBundle(path, { hmacKey: TEST_KEY })) + .rejects.toThrow("providerRevision"); + } finally { + await rm(root, { recursive: true, force: true }); + } + } + } + }); + + test("enforces the exact UTF-8 byte bound for attachment MIME types", async () => { + const cases = [ + { value: "m".repeat(256), bytes: 256, accepted: true }, + { value: "m".repeat(257), bytes: 257, accepted: false }, + { value: "é".repeat(128), bytes: 256, accepted: true }, + { value: "é".repeat(129), bytes: 258, accepted: false }, + ] as const; + for (const [index, candidate] of cases.entries()) { + const root = await mkdtemp(join(tmpdir(), `message-like-me-bundle-mime-${index}-`)); + try { + expect(Buffer.byteLength(candidate.value, "utf8")).toBe(candidate.bytes); + const records = syntheticBundleRecords(); + const attachments = records.message[1]!.attachments as Array>; + attachments[0]!.mimeType = candidate.value; + const path = await writeSyntheticMessageBundle(root, records); + if (candidate.accepted) { + const bundle = await readMessageBundle(path, { hmacKey: TEST_KEY }); + expect(bundle.sources).toHaveLength(1); + } else { + await expect(readMessageBundle(path, { hmacKey: TEST_KEY })) + .rejects.toThrow("mimeType"); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + } + }); + test("rejects deeply nested foreign fields before recursive canonicalization", async () => { const root = await mkdtemp(join(tmpdir(), "message-like-me-bundle-depth-")); const deepValue = `${"[".repeat(20_000)}0${"]".repeat(20_000)}`; diff --git a/src/bundle.ts b/src/bundle.ts index 0cff404..cfcdec2 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -212,6 +212,10 @@ function identifier(value: unknown, label: string): string { return result; } +function nullableIdentifier(value: unknown, label: string): string | null { + return value === null ? null : identifier(value, label); +} + function token(value: unknown, label: string, maximum = 128): string { const result = boundedText(value, label, maximum); if (!/^[a-z0-9](?:[a-z0-9._+-]*[a-z0-9])?$/u.test(result)) { @@ -292,7 +296,7 @@ function parseProvenance(value: unknown, label: string): Provenance { exactKeys(record, ["providerId", "providerRevision", "observedAt", "connectedAccountProviderId"], label); return Object.freeze({ providerId: identifier(record.providerId, `${label}.providerId`), - providerRevision: nullableText(record.providerRevision, `${label}.providerRevision`, MAX_IDENTIFIER_BYTES), + providerRevision: nullableIdentifier(record.providerRevision, `${label}.providerRevision`), observedAt: timestamp(record.observedAt, `${label}.observedAt`), connectedAccountProviderId: identifier( record.connectedAccountProviderId, @@ -418,7 +422,7 @@ function parseDeletion(value: unknown, label: string): MessageRecord["deletion"] return Object.freeze({ state: oneOf(record.state, ["revoked", "deleted-for-me", "revoked-and-deleted-for-me"] as const, `${label}.state`), observedAt: timestamp(record.observedAt, `${label}.observedAt`), - providerRevision: nullableText(record.providerRevision, `${label}.providerRevision`, MAX_IDENTIFIER_BYTES), + providerRevision: nullableIdentifier(record.providerRevision, `${label}.providerRevision`), }); } @@ -500,7 +504,7 @@ function parseTombstone(record: JsonObject, label: string): TombstoneRecord { entityProviderId: identifier(record.entityProviderId, `${label}.entityProviderId`), deletedAt: timestamp(record.deletedAt, `${label}.deletedAt`), scope: oneOf(record.scope, ["remote", "local", "unknown"] as const, `${label}.scope`), - providerRevision: nullableText(record.providerRevision, `${label}.providerRevision`, MAX_IDENTIFIER_BYTES), + providerRevision: nullableIdentifier(record.providerRevision, `${label}.providerRevision`), }); } From 6e2e620e65170f8bda0bedb553851e6d819e0b8c Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 22 Aug 2026 18:00:34 -0400 Subject: [PATCH 5/7] fix: keep reaction aggregates bounded and private --- CHANGELOG.md | 3 ++- README.md | 8 ++++--- SECURITY.md | 7 ++++-- dist/cli.js | 23 ++----------------- dist/types.d.ts | 7 ------ docs/methodology.md | 4 +++- site/app/readme.generated.ts | 2 +- src/commands.test.ts | 22 ++++++++++++++---- src/metrics.test.ts | 44 +++++++++++++++++++++++++----------- src/metrics.ts | 22 ------------------ src/types.ts | 7 ------ 11 files changed, 66 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1770729..10d8af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ progress, retained-shard validation, and atomic seven-file publication. - Partition sessions, bursts, and response episodes by conversation, preserve truncated text bubbles as tempo evidence, and count undated reactions without - inventing timestamps. + inventing timestamps or exposing raw provider reaction values in aggregate + output. - Upgrade existing version-two stores in place while retaining conversations, profiles, study packets, and evidence provenance. diff --git a/README.md b/README.md index 0152e20..635b34d 100644 --- a/README.md +++ b/README.md @@ -182,9 +182,11 @@ Sessions, bursts, and response episodes never cross a source conversation boundary. Person scopes spanning several apps expose a sorted `services` breakdown instead of hiding the mixed-channel evidence behind a null service. Reactions with no provider timestamp still contribute to reaction counts and -direction, but never to temporal metrics. Session and burst gaps are -configurable seconds and are recorded with each result. They are segmentation -choices, not universal facts about conversation. +direction, but never to temporal metrics. Raw provider reaction values remain +private; ordinary metrics and drafting context expose only fixed-size counts, +direction, datedness, and the outgoing reaction ratio. Session and burst gaps +are configurable seconds and are recorded with each result. They are +segmentation choices, not universal facts about conversation. Pass `--private` to `contacts list` or `contacts show` only when you need to resolve a pseudonymous contact to its local private label or participants. diff --git a/SECURITY.md b/SECURITY.md index f698653..a36a5a4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -128,8 +128,11 @@ it. ## Inspection and study packets Aggregate contact, session, tempo, and surface-style views omit message bodies -and private labels by default. `--private` deliberately reveals local private -identity fields. Use it only when the current task needs that mapping. +and private labels by default. Raw provider reaction values also remain private; +aggregate and drafting-context views expose only fixed-size reaction counts, +direction, datedness, and the outgoing reaction ratio. `--private` deliberately +reveals local private identity fields. Use it only when the current task needs +that mapping. `contacts resolve QUERY --private` performs bounded exact matching against private labels. It does not do prefix, substring, phonetic, or fuzzy matching, diff --git a/dist/cli.js b/dist/cli.js index a7b84ab..314c7be 100755 --- a/dist/cli.js +++ b/dist/cli.js @@ -3072,23 +3072,6 @@ function reactionMetrics(messages, facts) { const incoming = reactions.filter(({ direction }) => direction === "incoming").length; const unknownDirection = reactions.length - outgoing - incoming; const outgoingActions = messages.filter(({ message }) => message.kind !== "reaction" && message.direction === "outgoing" && timelineEligible(message)).length + outgoing; - const bodies2 = new Map; - for (const reaction of reactions) { - const counts = bodies2.get(reaction.body) ?? { - total: 0, - incoming: 0, - outgoing: 0, - unknownDirection: 0 - }; - counts.total += 1; - if (reaction.direction === "incoming") - counts.incoming += 1; - else if (reaction.direction === "outgoing") - counts.outgoing += 1; - else - counts.unknownDirection += 1; - bodies2.set(reaction.body, counts); - } return Object.freeze({ total: reactions.length, incoming, @@ -3096,8 +3079,7 @@ function reactionMetrics(messages, facts) { unknownDirection, dated: reactions.filter(({ reactedAt }) => reactedAt !== null).length, undated: reactions.filter(({ reactedAt }) => reactedAt === null).length, - outgoingReactionRatio: ratio(outgoing, outgoingActions), - byBody: Object.freeze([...bodies2].map(([body, counts]) => Object.freeze({ body, ...counts })).sort((left, right) => right.total - left.total || left.body.localeCompare(right.body, "en-US"))) + outgoingReactionRatio: ratio(outgoing, outgoingActions) }); } function analyzeContact(messages, corpusRevision, contactId, options = {}) { @@ -3377,8 +3359,7 @@ function aggregateStudyMetrics(metrics) { unknownDirection: metrics.reactions.unknownDirection, dated: metrics.reactions.dated, undated: metrics.reactions.undated, - outgoingReactionRatio: metrics.reactions.outgoingReactionRatio, - byBody: metrics.reactions.byBody + outgoingReactionRatio: metrics.reactions.outgoingReactionRatio }), surface: Object.freeze({ outgoingTextMessages: metrics.surface.outgoingTextMessages, diff --git a/dist/types.d.ts b/dist/types.d.ts index 5252ea7..3415200 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -254,13 +254,6 @@ export type ReactionMetrics = Readonly<{ dated: number; undated: number; outgoingReactionRatio: number; - byBody: readonly Readonly<{ - body: string; - total: number; - incoming: number; - outgoing: number; - unknownDirection: number; - }>[]; }>; export type ContactMetrics = Readonly<{ schemaVersion: typeof METRICS_SCHEMA_VERSION; diff --git a/docs/methodology.md b/docs/methodology.md index 33b6c32..69ded83 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -60,7 +60,9 @@ The analysis uses several operational units: message. It is distinct from a reaction or an ordinary adjacent response. - A **reaction** is counted as interaction behavior, not authored prose. A reaction without a provider timestamp contributes to counts and direction - but not to temporal order, sessions, bursts, or response episodes. + but not to temporal order, sessions, bursts, or response episodes. Raw + provider reaction values remain private and are not categorical dimensions + in aggregate metrics or drafting context. Five minutes and eight hours are reproducible segmentation parameters, not claims about natural conversational boundaries. Every metrics artifact records diff --git a/site/app/readme.generated.ts b/site/app/readme.generated.ts index 17b5b16..4d45c49 100644 --- a/site/app/readme.generated.ts +++ b/site/app/readme.generated.ts @@ -1,2 +1,2 @@ // Generated from ../README.md by scripts/sync-readme.ts. -export const readmeHtml = "

Message Like Me

\n

A local-first CLI and Agent Skill for studying private messaging history and\ndrafting messages that sound like you.

\n

Message Like Me turns private local messaging history into deterministic\nconversation metrics, bounded study packets, and reusable style profiles. It\nreads native iMessage history and strict local source bundles, including\nmulti-account Beeper exports produced through Wrench. Its Agent Skill teaches\nCodex, Claude, and other coding agents how to interpret those local artifacts\nand draft unsent replies in your voice.

\n

The CLI does not call an AI service, authenticate with a product account, send\nmessages, or operate Messages. The agent already running the skill supplies the\nsemantic analysis and drafting judgment.

\n

This is an evidence layer for relationship-aware drafting, not a digital clone.\nIt does not train a model, represent your identity, infer your beliefs, or claim\nthat a draft is what you would have written. Your current meaning, facts, and\nintent outrank historical style.

\n

Install

\n

Message Like Me requires Bun 1.3.14 or newer. Install the immutable public\nrelease from GitHub, then install the Agent Skill:

\n
bun add --global github:hraness/message-like-me#v0.3.0\nmessagelikeme skill install\n
\n

Start a new agent session after installing the skill. The default target is\nCodex at user scope. Other supported targets and project-local installation are\navailable explicitly:

\n
messagelikeme skill install --target claude\nmessagelikeme skill install --target agents --scope project\nmessagelikeme skill path\n
\n

Message Like Me is distributed directly through GitHub and is not published to\nnpm.

\n

Start with private local history

\n

Initialize the private data store and inspect its location:

\n
messagelikeme init\nmessagelikeme doctor --json\n
\n

On macOS, the default store is:

\n
~/Library/Application Support/Message Like Me/\n
\n

The directory is private to the current user. It contains a local SQLite\ndatabase, stored profiles, and a private installation key used to derive\nstable pseudonymous IDs. Study packets are written only to the explicit path\nyou choose. You can put the store elsewhere by placing\n--data-dir /absolute/private/path before the command.

\n

Import the current user's iMessage database:

\n
messagelikeme ingest imessage --json\n
\n

The default source is the current user's Messages chat.db. Use --database\nonly to name another caller-owned physical database:

\n
messagelikeme ingest imessage --database /absolute/path/to/chat.db --json\n
\n

Ingestion validates the source schema and ownership, makes a stable private\ncopy of the database and its transactional sidecars, and opens only that copy\nwith SQLite. It does not change Messages, chat.db, or its sidecars. macOS may\nrequire permission for the terminal or agent host to read Messages data.

\n

To study accounts connected through Beeper, first ask Wrench to create a new\nprivate Message Like Me bundle:

\n
wrench beeper export-message-like-me \\\n  --auth <beeper-auth-id> \\\n  --output /absolute/private/path/beeper-bundle \\\n  --json\n
\n

The optional --limit-chats, --limit-messages, and --max-participants\nflags lower the export bounds. The output path must be a normalized absolute\npath to a directory that does not already exist. Wrench calls the pinned\nofficial Beeper CLI directly. It enumerates\nthe connected account realm, invokes export --no-attachments once per\naccount in deterministic order, and reports the account ordinal, elapsed-time\nheartbeats, and cumulative validated chat and message counts on stderr. It\nretains each private raw shard until it can atomically publish the complete\nmode-0700 seven-file bundle with mode-0600 files.

\n

The export does not use the separate\nBeeper Desktop API MCP project.\nThe CLI path supplies the bounded account snapshots and local files needed for\nhash validation, deterministic conversion, crash recovery, and atomic\npublication. Provider URLs and credentials are excluded. Message Like Me does\nnot receive the Beeper credential and does not call Beeper or Wrench itself.

\n

Ingest the finished directory, then inspect its redacted source health:

\n
messagelikeme ingest bundle --input /absolute/private/path/beeper-bundle --json\nmessagelikeme sources list --json\nmessagelikeme sources show <source-id> --json\n
\n

The importer verifies the fixed version-one inventory, canonical UTF-8 NDJSON,\nrecord and byte bounds, owner-only permissions, artifact digests, and manifest\ndigest before changing the store. One bundle may contain several connected\naccounts and networks; each becomes a separate source namespace. Native\niMessage and prior bundle sources remain alongside it.

\n

The complete interchange, integrity, identity, and reimport laws are in the\nversion-one local message bundle contract.

\n

Beeper exports describe bounded local observations. A later bounded export\nthat omits an older record does not delete retained history. Explicit deletion,\nremoval, replacement, and tombstone records suppress their target, and a later\nreappearance restores it. Older snapshots cannot overwrite newer state. Use\nsources show <source-id> --private --json only when you deliberately need the\nprivate provider account and source metadata.

\n

Optionally enrich and join direct conversations with private identities from\nmacOS Contacts:

\n
messagelikeme ingest contacts --json\n
\n

The default source is the current user's AddressBook directory. An explicit\nabsolute AddressBook root, Sources directory, store directory, or\nAddressBook-vN.abcddb file can be selected with --addressbook:

\n
messagelikeme ingest contacts \\\n  --addressbook /absolute/path/to/AddressBook \\\n  --json\n
\n

Contacts ingest may run before or after any message source. It reads only\nbounded name, email, and phone fields from a stable private copy. Exact\nnormalized email or E.164 phone handles can join several one-to-one threads\nfor the same AddressBook person into one analysis scope. A bundle conversation\nis eligible only when the producer positively marks its direct participant\nroster complete. Existing conversation IDs remain aliases for that person\nscope. Shared handles remain ambiguous, local phone numbers never gain a\nguessed country code, unmatched threads stay separate, and groups are never\ncollapsed to one person. Contact labels have their own revision, so a rename\ndoes not stale a messaging-style profile. messagelikeme doctor reports local\naggregate state without asking for an account or credential.

\n

Inspect behavior without exposing prose

\n

Contact listings and aggregate views omit private labels, handles, and message\nbodies by default:

\n
messagelikeme contacts list --min-outgoing 20 --json\nmessagelikeme contacts show <contact-id> --json\nmessagelikeme inspect tempo <contact-id> --session-gap 28800 --burst-gap 300 --json\nmessagelikeme inspect sessions <contact-id> --limit 20 --json\n
\n

The metrics cover conversation start and end, message counts, incoming and\noutgoing turns, within-session response latency, single-message versus\nmulti-message replies, surface prose features, multi-point response contexts,\nreactions, and explicit reply use. Incoming messages establish what you were\nresponding to; they are never counted as examples of your writing style.\nSessions, bursts, and response episodes never cross a source conversation\nboundary. Person scopes spanning several apps expose a sorted services\nbreakdown instead of hiding the mixed-channel evidence behind a null service.\nReactions with no provider timestamp still contribute to reaction counts and\ndirection, but never to temporal metrics. Session and burst gaps are\nconfigurable seconds and are recorded with each result. They are segmentation\nchoices, not universal facts about conversation.

\n

Pass --private to contacts list or contacts show only when you need to\nresolve a pseudonymous contact to its local private label or participants.

\n

When you already know the complete Contacts label, resolve only that exact\nprivate name instead of listing every label:

\n
messagelikeme contacts resolve "Exact Contact Name" --private --json\n
\n

Resolution is normalized for case and Unicode representation, but it does not\nperform prefix, substring, phonetic, or fuzzy matching. It returns only direct\nperson scopes and labels, never handles or message bodies.

\n

Build a style profile

\n

Aggregate metrics cannot explain why a short burst works in one context or why\na longer single message appears in another. For that semantic work, prepare a\nsmall, diverse study packet at an explicit private path:

\n
messagelikeme study prepare <contact-id> \\\n  --output /absolute/private/path/study.json \\\n  --before 2026-08-01T00:00:00.000Z \\\n  --limit 24 \\\n  --json\n
\n

study prepare and evaluate prepare are the only commands that write bounded\nmessage bodies outside the private database. Their outputs are mode 0600.\nA study packet contains incoming context and outgoing responses selected across\ndifferent response shapes; it is not a full transcript export. By default,\neach body is capped at 4 KiB, each example keeps at most 12 text messages per\ndirection, and the entire packet keeps at most 256 KiB of body text. Packet\ncoverage fields report every truncation or omission explicitly.

\n

Keep the JSON receipt with the analysis. Its packetSha256 binds the finished\nprofile to these exact packet bytes; the packet does not contain its own digest.

\n

--after is inclusive and --before is exclusive. Temporal bounds let you\nreserve later conversations for evaluation. Invoke $message-like-me in your\nagent and ask it to analyze that contact. The skill separates measured facts\nfrom inferred patterns, covers prose and tempo, studies how several inbound\npoints are handled, and treats reply links and tapbacks separately from written\ntext.

\n

The agent writes a schema-version-two profile and asks the CLI to validate and\nstore it:

\n
messagelikeme profile apply /absolute/private/path/profile.json --json\nmessagelikeme profile show <contact-id> --json\n
\n

A version-two profile records the global corpus revision for provenance, a\nperson-and-window-specific evidence revision for validity, the exact\nstudy-packet SHA-256, and the packet's non-body evidence manifest. Measured and\ninferred claims cite valid packet example IDs and record counterexamples,\nsupport counts, confidence, and drafting consequences. Messages for someone\nelse or outside the studied time window do not stale it; changes inside its\nactual evidence do.

\n

Export a profile only when you need an explicit private copy:

\n
messagelikeme profile export <contact-id> --output /absolute/private/path/profile.json\n
\n

Version-one profiles remain readable for migration, but new analyses should use\nschema/style-profile-v2.schema.json.

\n

Audit against later conversations

\n

Prepare a separate prompt and reference set from conversations after the study\ncutoff:

\n
messagelikeme evaluate prepare <contact-id> \\\n  --after 2026-08-01T00:00:00.000Z \\\n  --prompt-output /absolute/private/path/evaluation-prompts.json \\\n  --reference-output /absolute/private/path/evaluation-references.json \\\n  --json\n
\n

Give the agent only the prompt file and fix one candidate bubble sequence per\ncase before opening the reference file. Then compare intent coverage, factual\nmeaning, prose, bubble shape, explicit replies, privacy leakage, and\ncalibration. The files support a blind workflow but do not enforce one, and the\nhistorical response is one observation rather than a unique correct answer.\nThe CLI deliberately does not collapse these dimensions into a universal\nfidelity score. See the methodology.

\n

Draft an unsent reply

\n

Ask an agent with the installed $message-like-me skill to draft for a\npseudonymous contact. The compact deterministic context is available through:

\n
messagelikeme context <contact-id> --json\n
\n

The skill preserves your intended meaning, selects the applicable profile,\nand can express the result as one message or a realistic sequence of separate\nbubbles. It uses explicit replies only when your evidence and the current\ncontext support them.

\n

Drafting ends with text in the agent task. Message Like Me has no send, react,\nschedule, or messaging-application command.

\n

Command reference

\n

Run messagelikeme --help for the checked grammar. The public surfaces are:

\n
messagelikeme init [--json]\nmessagelikeme ingest imessage [--database PATH] [--json]\nmessagelikeme ingest contacts [--addressbook PATH] [--json]\nmessagelikeme ingest bundle --input ABS_PATH [--json]\nmessagelikeme sources list [--private] [--json]\nmessagelikeme sources show SOURCE_ID [--private] [--json]\nmessagelikeme contacts list [--min-outgoing N] [--limit N] [--private] [--json]\nmessagelikeme contacts show CONTACT_ID [--private] [--json]\nmessagelikeme contacts resolve QUERY --private [--limit N] [--json]\nmessagelikeme inspect tempo CONTACT_ID [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme inspect sessions CONTACT_ID [--limit N] [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme study prepare CONTACT_ID --output FILE [--limit N]\n  [--after ISO_TIMESTAMP] [--before ISO_TIMESTAMP]\n  [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme evaluate prepare CONTACT_ID --after ISO_TIMESTAMP\n  --prompt-output FILE --reference-output FILE [--before ISO_TIMESTAMP]\n  [--limit N] [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme profile apply FILE [--json]\nmessagelikeme profile show CONTACT_ID [--json]\nmessagelikeme profile export CONTACT_ID --output FILE [--json]\nmessagelikeme context CONTACT_ID [--json]\nmessagelikeme skill path [--json]\nmessagelikeme skill install [--target codex|claude|agents]\n  [--scope user|project] [--project PATH] [--force] [--json]\nmessagelikeme doctor [--json]\n
\n

Place global --data-dir PATH before the command.

\n

Privacy model

\n
    \n
  • The original chat.db and AddressBook databases remain authoritative.\nSQLite opens only stable private copies, never the source files or sidecars.
  • \n
  • Source bundles remain private caller-owned inputs. Import verifies their\nfixed inventory, canonical bytes, digests, bounds, and owner-only modes.
  • \n
  • The normalized corpus, profiles, and installation key stay in a private local\nstore with owner-only permissions.
  • \n
  • Stable source, contact, participant, conversation, message, and reaction IDs\nare derived with a private per-install HMAC key. Pseudonymous IDs are not\nencryption.
  • \n
  • Aggregate commands omit bodies and private labels. Study and evaluation\npackets are bounded, explicit body-bearing exports.
  • \n
  • Message text never goes to a Message Like Me server. There is no service,\naccount, auth flow, analytics client, or network-backed model call.
  • \n
  • Opening a study packet makes its bounded excerpts visible to the agent\nenvironment already running the skill. Use an agent environment whose data\nhandling you accept; the CLI cannot make a hosted agent local.
  • \n
  • Public fixtures are synthetic. Private corpora, profiles, packets, and drafts\ndo not belong in Git, issues, logs, packages, or examples.
  • \n
  • A draft is never sent.
  • \n
\n

Read SECURITY.md before integrating the library into another\ntool or handling a private packet outside the CLI. The\nmethodology defines every unit and evidence boundary;\nthe research review documents papers, neighboring OSS, and\nthe claims this project does not make.

\n

TypeScript library

\n

The package exports the versioned corpus, metrics, study-packet, and profile\ntypes plus deterministic canonical JSON and SHA-256 helpers:

\n
import type { ContactMetrics, StyleProfileV2 } from "@hraness/message-like-me"\nimport { canonicalJson, sha256 } from "@hraness/message-like-me"\n
\n

The library does not start the CLI, inspect Messages or Contacts, connect to a\nnetwork, or send a draft merely because it is imported.

\n

Development

\n
bun install --frozen-lockfile --ignore-scripts\nbun run check\n
\n

Tests use synthetic Messages and AddressBook databases plus synthetic source\nbundles and conversations. Never add a real message, handle, group title,\nattachment, contact record, private path, or derived profile to a fixture.

\n

The canonical repository is\nhraness/message-like-me.\nThe informational project page is\nmessagelikeme.com. The CLI does not connect to\nthe site, and the site never receives message or contact data.

\n

License

\n

MIT.

\n"; +export const readmeHtml = "

Message Like Me

\n

A local-first CLI and Agent Skill for studying private messaging history and\ndrafting messages that sound like you.

\n

Message Like Me turns private local messaging history into deterministic\nconversation metrics, bounded study packets, and reusable style profiles. It\nreads native iMessage history and strict local source bundles, including\nmulti-account Beeper exports produced through Wrench. Its Agent Skill teaches\nCodex, Claude, and other coding agents how to interpret those local artifacts\nand draft unsent replies in your voice.

\n

The CLI does not call an AI service, authenticate with a product account, send\nmessages, or operate Messages. The agent already running the skill supplies the\nsemantic analysis and drafting judgment.

\n

This is an evidence layer for relationship-aware drafting, not a digital clone.\nIt does not train a model, represent your identity, infer your beliefs, or claim\nthat a draft is what you would have written. Your current meaning, facts, and\nintent outrank historical style.

\n

Install

\n

Message Like Me requires Bun 1.3.14 or newer. Install the immutable public\nrelease from GitHub, then install the Agent Skill:

\n
bun add --global github:hraness/message-like-me#v0.3.0\nmessagelikeme skill install\n
\n

Start a new agent session after installing the skill. The default target is\nCodex at user scope. Other supported targets and project-local installation are\navailable explicitly:

\n
messagelikeme skill install --target claude\nmessagelikeme skill install --target agents --scope project\nmessagelikeme skill path\n
\n

Message Like Me is distributed directly through GitHub and is not published to\nnpm.

\n

Start with private local history

\n

Initialize the private data store and inspect its location:

\n
messagelikeme init\nmessagelikeme doctor --json\n
\n

On macOS, the default store is:

\n
~/Library/Application Support/Message Like Me/\n
\n

The directory is private to the current user. It contains a local SQLite\ndatabase, stored profiles, and a private installation key used to derive\nstable pseudonymous IDs. Study packets are written only to the explicit path\nyou choose. You can put the store elsewhere by placing\n--data-dir /absolute/private/path before the command.

\n

Import the current user's iMessage database:

\n
messagelikeme ingest imessage --json\n
\n

The default source is the current user's Messages chat.db. Use --database\nonly to name another caller-owned physical database:

\n
messagelikeme ingest imessage --database /absolute/path/to/chat.db --json\n
\n

Ingestion validates the source schema and ownership, makes a stable private\ncopy of the database and its transactional sidecars, and opens only that copy\nwith SQLite. It does not change Messages, chat.db, or its sidecars. macOS may\nrequire permission for the terminal or agent host to read Messages data.

\n

To study accounts connected through Beeper, first ask Wrench to create a new\nprivate Message Like Me bundle:

\n
wrench beeper export-message-like-me \\\n  --auth <beeper-auth-id> \\\n  --output /absolute/private/path/beeper-bundle \\\n  --json\n
\n

The optional --limit-chats, --limit-messages, and --max-participants\nflags lower the export bounds. The output path must be a normalized absolute\npath to a directory that does not already exist. Wrench calls the pinned\nofficial Beeper CLI directly. It enumerates\nthe connected account realm, invokes export --no-attachments once per\naccount in deterministic order, and reports the account ordinal, elapsed-time\nheartbeats, and cumulative validated chat and message counts on stderr. It\nretains each private raw shard until it can atomically publish the complete\nmode-0700 seven-file bundle with mode-0600 files.

\n

The export does not use the separate\nBeeper Desktop API MCP project.\nThe CLI path supplies the bounded account snapshots and local files needed for\nhash validation, deterministic conversion, crash recovery, and atomic\npublication. Provider URLs and credentials are excluded. Message Like Me does\nnot receive the Beeper credential and does not call Beeper or Wrench itself.

\n

Ingest the finished directory, then inspect its redacted source health:

\n
messagelikeme ingest bundle --input /absolute/private/path/beeper-bundle --json\nmessagelikeme sources list --json\nmessagelikeme sources show <source-id> --json\n
\n

The importer verifies the fixed version-one inventory, canonical UTF-8 NDJSON,\nrecord and byte bounds, owner-only permissions, artifact digests, and manifest\ndigest before changing the store. One bundle may contain several connected\naccounts and networks; each becomes a separate source namespace. Native\niMessage and prior bundle sources remain alongside it.

\n

The complete interchange, integrity, identity, and reimport laws are in the\nversion-one local message bundle contract.

\n

Beeper exports describe bounded local observations. A later bounded export\nthat omits an older record does not delete retained history. Explicit deletion,\nremoval, replacement, and tombstone records suppress their target, and a later\nreappearance restores it. Older snapshots cannot overwrite newer state. Use\nsources show <source-id> --private --json only when you deliberately need the\nprivate provider account and source metadata.

\n

Optionally enrich and join direct conversations with private identities from\nmacOS Contacts:

\n
messagelikeme ingest contacts --json\n
\n

The default source is the current user's AddressBook directory. An explicit\nabsolute AddressBook root, Sources directory, store directory, or\nAddressBook-vN.abcddb file can be selected with --addressbook:

\n
messagelikeme ingest contacts \\\n  --addressbook /absolute/path/to/AddressBook \\\n  --json\n
\n

Contacts ingest may run before or after any message source. It reads only\nbounded name, email, and phone fields from a stable private copy. Exact\nnormalized email or E.164 phone handles can join several one-to-one threads\nfor the same AddressBook person into one analysis scope. A bundle conversation\nis eligible only when the producer positively marks its direct participant\nroster complete. Existing conversation IDs remain aliases for that person\nscope. Shared handles remain ambiguous, local phone numbers never gain a\nguessed country code, unmatched threads stay separate, and groups are never\ncollapsed to one person. Contact labels have their own revision, so a rename\ndoes not stale a messaging-style profile. messagelikeme doctor reports local\naggregate state without asking for an account or credential.

\n

Inspect behavior without exposing prose

\n

Contact listings and aggregate views omit private labels, handles, and message\nbodies by default:

\n
messagelikeme contacts list --min-outgoing 20 --json\nmessagelikeme contacts show <contact-id> --json\nmessagelikeme inspect tempo <contact-id> --session-gap 28800 --burst-gap 300 --json\nmessagelikeme inspect sessions <contact-id> --limit 20 --json\n
\n

The metrics cover conversation start and end, message counts, incoming and\noutgoing turns, within-session response latency, single-message versus\nmulti-message replies, surface prose features, multi-point response contexts,\nreactions, and explicit reply use. Incoming messages establish what you were\nresponding to; they are never counted as examples of your writing style.\nSessions, bursts, and response episodes never cross a source conversation\nboundary. Person scopes spanning several apps expose a sorted services\nbreakdown instead of hiding the mixed-channel evidence behind a null service.\nReactions with no provider timestamp still contribute to reaction counts and\ndirection, but never to temporal metrics. Raw provider reaction values remain\nprivate; ordinary metrics and drafting context expose only fixed-size counts,\ndirection, datedness, and the outgoing reaction ratio. Session and burst gaps\nare configurable seconds and are recorded with each result. They are\nsegmentation choices, not universal facts about conversation.

\n

Pass --private to contacts list or contacts show only when you need to\nresolve a pseudonymous contact to its local private label or participants.

\n

When you already know the complete Contacts label, resolve only that exact\nprivate name instead of listing every label:

\n
messagelikeme contacts resolve "Exact Contact Name" --private --json\n
\n

Resolution is normalized for case and Unicode representation, but it does not\nperform prefix, substring, phonetic, or fuzzy matching. It returns only direct\nperson scopes and labels, never handles or message bodies.

\n

Build a style profile

\n

Aggregate metrics cannot explain why a short burst works in one context or why\na longer single message appears in another. For that semantic work, prepare a\nsmall, diverse study packet at an explicit private path:

\n
messagelikeme study prepare <contact-id> \\\n  --output /absolute/private/path/study.json \\\n  --before 2026-08-01T00:00:00.000Z \\\n  --limit 24 \\\n  --json\n
\n

study prepare and evaluate prepare are the only commands that write bounded\nmessage bodies outside the private database. Their outputs are mode 0600.\nA study packet contains incoming context and outgoing responses selected across\ndifferent response shapes; it is not a full transcript export. By default,\neach body is capped at 4 KiB, each example keeps at most 12 text messages per\ndirection, and the entire packet keeps at most 256 KiB of body text. Packet\ncoverage fields report every truncation or omission explicitly.

\n

Keep the JSON receipt with the analysis. Its packetSha256 binds the finished\nprofile to these exact packet bytes; the packet does not contain its own digest.

\n

--after is inclusive and --before is exclusive. Temporal bounds let you\nreserve later conversations for evaluation. Invoke $message-like-me in your\nagent and ask it to analyze that contact. The skill separates measured facts\nfrom inferred patterns, covers prose and tempo, studies how several inbound\npoints are handled, and treats reply links and tapbacks separately from written\ntext.

\n

The agent writes a schema-version-two profile and asks the CLI to validate and\nstore it:

\n
messagelikeme profile apply /absolute/private/path/profile.json --json\nmessagelikeme profile show <contact-id> --json\n
\n

A version-two profile records the global corpus revision for provenance, a\nperson-and-window-specific evidence revision for validity, the exact\nstudy-packet SHA-256, and the packet's non-body evidence manifest. Measured and\ninferred claims cite valid packet example IDs and record counterexamples,\nsupport counts, confidence, and drafting consequences. Messages for someone\nelse or outside the studied time window do not stale it; changes inside its\nactual evidence do.

\n

Export a profile only when you need an explicit private copy:

\n
messagelikeme profile export <contact-id> --output /absolute/private/path/profile.json\n
\n

Version-one profiles remain readable for migration, but new analyses should use\nschema/style-profile-v2.schema.json.

\n

Audit against later conversations

\n

Prepare a separate prompt and reference set from conversations after the study\ncutoff:

\n
messagelikeme evaluate prepare <contact-id> \\\n  --after 2026-08-01T00:00:00.000Z \\\n  --prompt-output /absolute/private/path/evaluation-prompts.json \\\n  --reference-output /absolute/private/path/evaluation-references.json \\\n  --json\n
\n

Give the agent only the prompt file and fix one candidate bubble sequence per\ncase before opening the reference file. Then compare intent coverage, factual\nmeaning, prose, bubble shape, explicit replies, privacy leakage, and\ncalibration. The files support a blind workflow but do not enforce one, and the\nhistorical response is one observation rather than a unique correct answer.\nThe CLI deliberately does not collapse these dimensions into a universal\nfidelity score. See the methodology.

\n

Draft an unsent reply

\n

Ask an agent with the installed $message-like-me skill to draft for a\npseudonymous contact. The compact deterministic context is available through:

\n
messagelikeme context <contact-id> --json\n
\n

The skill preserves your intended meaning, selects the applicable profile,\nand can express the result as one message or a realistic sequence of separate\nbubbles. It uses explicit replies only when your evidence and the current\ncontext support them.

\n

Drafting ends with text in the agent task. Message Like Me has no send, react,\nschedule, or messaging-application command.

\n

Command reference

\n

Run messagelikeme --help for the checked grammar. The public surfaces are:

\n
messagelikeme init [--json]\nmessagelikeme ingest imessage [--database PATH] [--json]\nmessagelikeme ingest contacts [--addressbook PATH] [--json]\nmessagelikeme ingest bundle --input ABS_PATH [--json]\nmessagelikeme sources list [--private] [--json]\nmessagelikeme sources show SOURCE_ID [--private] [--json]\nmessagelikeme contacts list [--min-outgoing N] [--limit N] [--private] [--json]\nmessagelikeme contacts show CONTACT_ID [--private] [--json]\nmessagelikeme contacts resolve QUERY --private [--limit N] [--json]\nmessagelikeme inspect tempo CONTACT_ID [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme inspect sessions CONTACT_ID [--limit N] [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme study prepare CONTACT_ID --output FILE [--limit N]\n  [--after ISO_TIMESTAMP] [--before ISO_TIMESTAMP]\n  [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme evaluate prepare CONTACT_ID --after ISO_TIMESTAMP\n  --prompt-output FILE --reference-output FILE [--before ISO_TIMESTAMP]\n  [--limit N] [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme profile apply FILE [--json]\nmessagelikeme profile show CONTACT_ID [--json]\nmessagelikeme profile export CONTACT_ID --output FILE [--json]\nmessagelikeme context CONTACT_ID [--json]\nmessagelikeme skill path [--json]\nmessagelikeme skill install [--target codex|claude|agents]\n  [--scope user|project] [--project PATH] [--force] [--json]\nmessagelikeme doctor [--json]\n
\n

Place global --data-dir PATH before the command.

\n

Privacy model

\n
    \n
  • The original chat.db and AddressBook databases remain authoritative.\nSQLite opens only stable private copies, never the source files or sidecars.
  • \n
  • Source bundles remain private caller-owned inputs. Import verifies their\nfixed inventory, canonical bytes, digests, bounds, and owner-only modes.
  • \n
  • The normalized corpus, profiles, and installation key stay in a private local\nstore with owner-only permissions.
  • \n
  • Stable source, contact, participant, conversation, message, and reaction IDs\nare derived with a private per-install HMAC key. Pseudonymous IDs are not\nencryption.
  • \n
  • Aggregate commands omit bodies and private labels. Study and evaluation\npackets are bounded, explicit body-bearing exports.
  • \n
  • Message text never goes to a Message Like Me server. There is no service,\naccount, auth flow, analytics client, or network-backed model call.
  • \n
  • Opening a study packet makes its bounded excerpts visible to the agent\nenvironment already running the skill. Use an agent environment whose data\nhandling you accept; the CLI cannot make a hosted agent local.
  • \n
  • Public fixtures are synthetic. Private corpora, profiles, packets, and drafts\ndo not belong in Git, issues, logs, packages, or examples.
  • \n
  • A draft is never sent.
  • \n
\n

Read SECURITY.md before integrating the library into another\ntool or handling a private packet outside the CLI. The\nmethodology defines every unit and evidence boundary;\nthe research review documents papers, neighboring OSS, and\nthe claims this project does not make.

\n

TypeScript library

\n

The package exports the versioned corpus, metrics, study-packet, and profile\ntypes plus deterministic canonical JSON and SHA-256 helpers:

\n
import type { ContactMetrics, StyleProfileV2 } from "@hraness/message-like-me"\nimport { canonicalJson, sha256 } from "@hraness/message-like-me"\n
\n

The library does not start the CLI, inspect Messages or Contacts, connect to a\nnetwork, or send a draft merely because it is imported.

\n

Development

\n
bun install --frozen-lockfile --ignore-scripts\nbun run check\n
\n

Tests use synthetic Messages and AddressBook databases plus synthetic source\nbundles and conversations. Never add a real message, handle, group title,\nattachment, contact record, private path, or derived profile to a fixture.

\n

The canonical repository is\nhraness/message-like-me.\nThe informational project page is\nmessagelikeme.com. The CLI does not connect to\nthe site, and the site never receives message or contact data.

\n

License

\n

MIT.

\n"; diff --git a/src/commands.test.ts b/src/commands.test.ts index f3783ca..d9c64e2 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -158,18 +158,30 @@ describe("messagelikeme CLI", () => { expect(await main([ "--data-dir", state, "inspect", "tempo", contacts.contacts[0]!.id, "--json", ], capture.io)).toBe(0); - expect(JSON.parse(capture.stdout())).toMatchObject({ + const tempoOutput = capture.stdout(); + expect(JSON.parse(tempoOutput)).toMatchObject({ reactions: { total: 2, incoming: 1, outgoing: 1, undated: 1, - byBody: [ - { body: "heart", total: 1 }, - { body: "thumbs-up", total: 1 }, - ], }, }); + expect(tempoOutput).not.toContain("heart"); + expect(tempoOutput).not.toContain("thumbs-up"); + expect(Object.hasOwn( + (JSON.parse(tempoOutput) as { reactions: Record }).reactions, + "byBody", + )).toBeFalse(); + + capture.clear(); + expect(await main([ + "--data-dir", state, "context", contacts.contacts[0]!.id, "--json", + ], capture.io)).toBe(0); + const contextOutput = capture.stdout(); + expect(contextOutput).not.toContain("heart"); + expect(contextOutput).not.toContain("thumbs-up"); + expect(contextOutput).not.toContain("byBody"); capture.clear(); expect(await main([ diff --git a/src/metrics.test.ts b/src/metrics.test.ts index 3e2828d..4733086 100644 --- a/src/metrics.test.ts +++ b/src/metrics.test.ts @@ -189,13 +189,6 @@ describe("analyzeContact", () => { dated: 2, undated: 0, outgoingReactionRatio: 0.125, - byBody: [{ - body: "unknown", - total: 2, - incoming: 1, - outgoing: 1, - unknownDirection: 0, - }], }); expect(analyzeContact(messages, CORPUS_REVISION, CONTACT_ID, { reactionFacts: [] }).reactions) .toEqual(metrics.reactions); @@ -271,7 +264,6 @@ describe("analyzeContact", () => { dated: 0, undated: 0, outgoingReactionRatio: 0, - byBody: [], }); expect(() => analyzeContact([], CORPUS_REVISION, CONTACT_ID, { sessionGapSeconds: 30, @@ -324,13 +316,38 @@ describe("analyzeContact", () => { dated: 0, undated: 3, outgoingReactionRatio: 0.5, - byBody: [ - { body: "heart", total: 2, incoming: 1, outgoing: 1, unknownDirection: 0 }, - { body: "question", total: 1, incoming: 0, outgoing: 0, unknownDirection: 1 }, - ], }); }); + test("keeps aggregate output fixed-size for many unique maximum-size reaction values", () => { + const reactionFacts = Object.freeze(Array.from({ length: 2_048 }, (_, index) => { + const prefix = `private-reaction-value-${index}:`; + return Object.freeze({ + id: `reaction-${index}`, + externalId: `external-${index}`, + targetExternalId: "target-1", + conversationId: "conversation_1", + direction: index % 3 === 0 ? "incoming" as const + : index % 3 === 1 ? "outgoing" as const + : null, + body: `${prefix}${"x".repeat(8_192 - prefix.length)}`, + reactedAt: null, + state: "active" as const, + }); + })); + expect(Buffer.byteLength(reactionFacts[0]!.body, "utf8")).toBe(8_192); + expect(Buffer.byteLength(reactionFacts.at(-1)!.body, "utf8")).toBe(8_192); + + const metrics = analyzeContact([], CORPUS_REVISION, CONTACT_ID, { reactionFacts }); + expect(metrics.reactions.total).toBe(reactionFacts.length); + expect(metrics.reactions.incoming + metrics.reactions.outgoing + + metrics.reactions.unknownDirection).toBe(reactionFacts.length); + expect(Object.hasOwn(metrics.reactions, "byBody")).toBeFalse(); + const encoded = JSON.stringify(metrics.reactions); + expect(Buffer.byteLength(encoded, "utf8")).toBeLessThan(256); + expect(encoded).not.toContain("private-reaction-value"); + }); + test("excludes retracted and system records from style and tempo evidence", () => { const messages = [ message("eligible-in", 1, "2024-05-01T00:00:00.000Z", "incoming", "Are you coming?"), @@ -385,7 +402,6 @@ describe("analyzeContact", () => { dated: 0, undated: 0, outgoingReactionRatio: 0, - byBody: [], }); }); }); @@ -507,6 +523,8 @@ describe("buildStudyPacket", () => { expect(Object.hasOwn(packet.metrics, "responses")).toBeFalse(); expect(Object.hasOwn(packet.metrics, "corpusRevision")).toBeFalse(); expect(Object.hasOwn(packet.metrics, "contactId")).toBeFalse(); + expect(Object.hasOwn(packet.metrics.reactions, "byBody")).toBeFalse(); + expect(JSON.stringify(packet.metrics.reactions)).not.toContain(':"unknown"'); expect(packet.examples).toHaveLength(2); expect(packet.examples[0]!.messages.map(({ id }) => id)).toEqual(["m01", "m02", "m03", "m04"]); expect(packet.examples[0]!.messages.map(({ offsetSeconds }) => offsetSeconds)).toEqual([0, 10, 70, 90]); diff --git a/src/metrics.ts b/src/metrics.ts index a619347..09adc19 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -549,25 +549,6 @@ function reactionMetrics( message.kind !== "reaction" && message.direction === "outgoing" && timelineEligible(message)).length + outgoing; - const bodies = new Map(); - for (const reaction of reactions) { - const counts = bodies.get(reaction.body) ?? { - total: 0, - incoming: 0, - outgoing: 0, - unknownDirection: 0, - }; - counts.total += 1; - if (reaction.direction === "incoming") counts.incoming += 1; - else if (reaction.direction === "outgoing") counts.outgoing += 1; - else counts.unknownDirection += 1; - bodies.set(reaction.body, counts); - } return Object.freeze({ total: reactions.length, incoming, @@ -576,8 +557,6 @@ function reactionMetrics( dated: reactions.filter(({ reactedAt }) => reactedAt !== null).length, undated: reactions.filter(({ reactedAt }) => reactedAt === null).length, outgoingReactionRatio: ratio(outgoing, outgoingActions), - byBody: Object.freeze([...bodies].map(([body, counts]) => Object.freeze({ body, ...counts })) - .sort((left, right) => right.total - left.total || left.body.localeCompare(right.body, "en-US"))), }); } @@ -958,7 +937,6 @@ function aggregateStudyMetrics(metrics: ContactMetrics): StudyAggregateMetrics { dated: metrics.reactions.dated, undated: metrics.reactions.undated, outgoingReactionRatio: metrics.reactions.outgoingReactionRatio, - byBody: metrics.reactions.byBody, }), surface: Object.freeze({ outgoingTextMessages: metrics.surface.outgoingTextMessages, diff --git a/src/types.ts b/src/types.ts index 4deb843..edc4e31 100644 --- a/src/types.ts +++ b/src/types.ts @@ -261,13 +261,6 @@ export type ReactionMetrics = Readonly<{ dated: number; undated: number; outgoingReactionRatio: number; - byBody: readonly Readonly<{ - body: string; - total: number; - incoming: number; - outgoing: number; - unknownDirection: number; - }>[]; }>; export type ContactMetrics = Readonly<{ From 4861da6f3842b0a19f5a4b6ac29315cf941a7245 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 22 Aug 2026 18:06:05 -0400 Subject: [PATCH 6/7] fix: keep retained source state coherent --- dist/cli.js | 30 +++++++++++++++- docs/local-message-bundle-v1.md | 5 +-- src/bundle.test.ts | 18 ++++++++-- src/store.test.ts | 61 +++++++++++++++++++++++++++++++++ src/store.ts | 31 ++++++++++++++++- 5 files changed, 139 insertions(+), 6 deletions(-) diff --git a/dist/cli.js b/dist/cli.js index 314c7be..b9cbd04 100755 --- a/dist/cli.js +++ b/dist/cli.js @@ -5447,6 +5447,18 @@ class LocalStore { manifest_sha256=excluded.manifest_sha256,identity_json=excluded.identity_json, warnings_json=excluded.warnings_json,ingested_at=excluded.ingested_at `); + const relabelSourceConversations = this.#database.query(` + UPDATE conversations SET service=? + WHERE id IN ( + SELECT conversation_id FROM conversation_sources WHERE source_id=? + ) + `); + const relabelSourceMessages = this.#database.query(` + UPDATE messages SET service=? + WHERE id IN ( + SELECT message_id FROM message_provenance WHERE source_id=? + ) + `); const upsertConversation = this.#database.query(` INSERT INTO conversations( id,source_key,private_label,service,participant_count, @@ -5513,7 +5525,7 @@ class LocalStore { let changedAny = false; for (const snapshot of snapshots) { const existing = get(this.#database, ` - SELECT kind,input_revision,revision,generated_at,manifest_sha256 + SELECT kind,network,input_revision,revision,generated_at,manifest_sha256 FROM corpus_sources WHERE id=? `, snapshot.source.id); if (existing !== null && existing.kind !== snapshot.source.kind) { @@ -5548,6 +5560,10 @@ class LocalStore { } } upsertSource.run(snapshot.source.id, snapshot.source.kind, snapshot.source.provider, snapshot.source.network, snapshot.source.accountId, snapshot.source.externalId, snapshot.source.revision, existing?.revision ?? snapshot.source.revision, snapshot.source.generatedAt, canonicalJson(snapshot.source.producer), canonicalJson(snapshot.source.coverage), snapshot.source.manifestSha256, canonicalJson(snapshot.source.identity), canonicalJson(snapshot.source.warnings), ingestedAt); + if (existing !== null && existing.network !== snapshot.source.network) { + relabelSourceConversations.run(snapshot.source.network, snapshot.source.id); + relabelSourceMessages.run(snapshot.source.network, snapshot.source.id); + } const conversationProvenance = new Map(snapshot.conversationProvenance.map((value) => [value.conversationId, value])); for (const conversation of snapshot.conversations) { const owner = get(this.#database, ` @@ -5792,6 +5808,12 @@ class LocalStore { SELECT 1 FROM corpus_source_suppressions suppression WHERE suppression.source_id=source.id AND suppression.kind='reaction' AND suppression.local_id=reaction.id AND suppression.suppressed=1 + ) + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id AND suppression.kind='conversation' + AND suppression.local_id=reaction.conversation_id + AND suppression.suppressed=1 )) AS reactions, (SELECT count(*) FROM corpus_reaction_facts reaction WHERE reaction.source_id=source.id AND reaction.state='active' @@ -5800,6 +5822,12 @@ class LocalStore { SELECT 1 FROM corpus_source_suppressions suppression WHERE suppression.source_id=source.id AND suppression.kind='reaction' AND suppression.local_id=reaction.id AND suppression.suppressed=1 + ) + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id AND suppression.kind='conversation' + AND suppression.local_id=reaction.conversation_id + AND suppression.suppressed=1 )) AS undated_reactions FROM corpus_sources source LEFT JOIN conversation_sources ownership ON ownership.source_id=source.id diff --git a/docs/local-message-bundle-v1.md b/docs/local-message-bundle-v1.md index 3b93d0b..99309d6 100644 --- a/docs/local-message-bundle-v1.md +++ b/docs/local-message-bundle-v1.md @@ -175,8 +175,9 @@ A reaction has a required target provider message ID and an optional bundle-local target. When present, the local target must resolve to the same provider coordinate. `reactedAt` is nullable because the provider may not expose a reaction time. Producers never synthesize one. Active undated -reactions contribute to body and direction counts but never enter the message -timeline, sessions, bursts, response episodes, or latency metrics. +reactions contribute to fixed aggregate reaction counts, direction counts, and +timestamp-coverage counts but never enter the message timeline, sessions, +bursts, response episodes, or latency metrics. Tombstones identify a conversation, message, or reaction kind, required provider coordinate, optional bundle-local coordinate, deletion time, scope, diff --git a/src/bundle.test.ts b/src/bundle.test.ts index 7fa694d..fdfcea9 100644 --- a/src/bundle.test.ts +++ b/src/bundle.test.ts @@ -642,6 +642,11 @@ describe("private local message bundle", () => { for (const values of Object.values(changedRecords)) { for (const record of values) record.network = "whatsapp-business"; } + changedRecords.participant = [changedRecords.participant[0]!]; + changedRecords.conversation = []; + changedRecords.message = []; + changedRecords.reaction = []; + changedRecords.tombstone = []; const changedPath = await writeSyntheticMessageBundle(root, changedRecords, { directoryName: "renamed-network", createdAt: "2026-08-20T12:06:00.000Z", @@ -649,8 +654,8 @@ describe("private local message bundle", () => { const first = (await readMessageBundle(firstPath, { hmacKey: TEST_KEY })).sources[0]!; const changed = (await readMessageBundle(changedPath, { hmacKey: TEST_KEY })).sources[0]!; expect(changed.source.id).toBe(first.source.id); - expect(changed.conversations.map(({ id }) => id)).toEqual(first.conversations.map(({ id }) => id)); - expect(changed.messages.map(({ id }) => id).sort()).toEqual(first.messages.map(({ id }) => id).sort()); + expect(changed.conversations).toEqual([]); + expect(changed.messages).toEqual([]); const store = LocalStore.open(join(root, "network-store.sqlite3")); try { @@ -662,6 +667,15 @@ describe("private local message bundle", () => { conversations: 1, messages: 4, }]); + const contactId = first.conversations[0]!.id; + expect(store.conversation(contactId, true)).toMatchObject({ + service: "whatsapp-business", + services: ["whatsapp-business"], + }); + const retainedMessages = store.contactCorpus(contactId)?.messages; + expect(retainedMessages).toHaveLength(4); + expect(retainedMessages?.every(({ service }) => service === "whatsapp-business")) + .toBeTrue(); } finally { store.close(); } diff --git a/src/store.test.ts b/src/store.test.ts index 8ed583c..26ff4e0 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -1018,6 +1018,67 @@ describe("local corpus store", () => { } }); + test("hides source reaction health with a conversation tombstone and restores it on reappearance", async () => { + const root = await mkdtemp(join(tmpdir(), "message-like-me-source-conversation-suppression-")); + const store = LocalStore.open(join(root, "store.sqlite3")); + const message = bundleMessage("a", 1, "2026-08-20T10:00:00.000Z"); + try { + store.replaceSources([bundleSnapshot({ + revision: "1".repeat(64), + generatedAt: "2026-08-21T12:01:00.000Z", + messages: [message], + reactions: [bundleReaction()], + })], "2026-08-21T12:01:01.000Z"); + expect(store.source(BUNDLE_SOURCE_ID)).toMatchObject({ + conversations: 1, + messages: 1, + reactions: 1, + undatedReactions: 1, + }); + + store.replaceSources([bundleSnapshot({ + revision: "2".repeat(64), + generatedAt: "2026-08-21T12:02:00.000Z", + messages: [message], + reactions: [bundleReaction()], + deletions: [{ + entityKind: "conversation", + localEntityId: BUNDLE_CONVERSATION_ID, + externalId: "provider-conversation-bundle", + deletedAt: "2026-08-21T12:02:00.000Z", + reason: "tombstone", + }], + })], "2026-08-21T12:02:01.000Z"); + expect(store.source(BUNDLE_SOURCE_ID)).toMatchObject({ + conversations: 0, + messages: 0, + reactions: 0, + undatedReactions: 0, + }); + expect(store.contactCorpus(BUNDLE_CONVERSATION_ID)).toBeNull(); + + store.replaceSources([bundleSnapshot({ + revision: "3".repeat(64), + generatedAt: "2026-08-21T12:03:00.000Z", + messages: [message], + reactions: [bundleReaction()], + })], "2026-08-21T12:03:01.000Z"); + expect(store.source(BUNDLE_SOURCE_ID)).toMatchObject({ + conversations: 1, + messages: 1, + reactions: 1, + undatedReactions: 1, + }); + expect(store.contactCorpus(BUNDLE_CONVERSATION_ID)).toMatchObject({ + messages: [{ id: message.id }], + reactions: [{ id: "bundle-reaction-r" }], + }); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + test("authoritative reaction absence and reappearance affect only that source", async () => { const root = await mkdtemp(join(tmpdir(), "message-like-me-reaction-reappearance-")); const store = LocalStore.open(join(root, "store.sqlite3")); diff --git a/src/store.ts b/src/store.ts index 8100d5c..34d39a3 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1672,6 +1672,18 @@ export class LocalStore { manifest_sha256=excluded.manifest_sha256,identity_json=excluded.identity_json, warnings_json=excluded.warnings_json,ingested_at=excluded.ingested_at `); + const relabelSourceConversations = this.#database.query(` + UPDATE conversations SET service=? + WHERE id IN ( + SELECT conversation_id FROM conversation_sources WHERE source_id=? + ) + `); + const relabelSourceMessages = this.#database.query(` + UPDATE messages SET service=? + WHERE id IN ( + SELECT message_id FROM message_provenance WHERE source_id=? + ) + `); const upsertConversation = this.#database.query(` INSERT INTO conversations( id,source_key,private_label,service,participant_count, @@ -1744,12 +1756,13 @@ export class LocalStore { for (const snapshot of snapshots) { const existing = get<{ kind: string; + network: string | null; input_revision: string; revision: string; generated_at: string | null; manifest_sha256: string | null; }>(this.#database, ` - SELECT kind,input_revision,revision,generated_at,manifest_sha256 + SELECT kind,network,input_revision,revision,generated_at,manifest_sha256 FROM corpus_sources WHERE id=? `, snapshot.source.id); if (existing !== null && existing.kind !== snapshot.source.kind) { @@ -1837,6 +1850,10 @@ export class LocalStore { canonicalJson(snapshot.source.warnings), ingestedAt, ); + if (existing !== null && existing.network !== snapshot.source.network) { + relabelSourceConversations.run(snapshot.source.network, snapshot.source.id); + relabelSourceMessages.run(snapshot.source.network, snapshot.source.id); + } const conversationProvenance = new Map( snapshot.conversationProvenance.map((value) => [value.conversationId, value]), ); @@ -2280,6 +2297,12 @@ export class LocalStore { SELECT 1 FROM corpus_source_suppressions suppression WHERE suppression.source_id=source.id AND suppression.kind='reaction' AND suppression.local_id=reaction.id AND suppression.suppressed=1 + ) + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id AND suppression.kind='conversation' + AND suppression.local_id=reaction.conversation_id + AND suppression.suppressed=1 )) AS reactions, (SELECT count(*) FROM corpus_reaction_facts reaction WHERE reaction.source_id=source.id AND reaction.state='active' @@ -2288,6 +2311,12 @@ export class LocalStore { SELECT 1 FROM corpus_source_suppressions suppression WHERE suppression.source_id=source.id AND suppression.kind='reaction' AND suppression.local_id=reaction.id AND suppression.suppressed=1 + ) + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id AND suppression.kind='conversation' + AND suppression.local_id=reaction.conversation_id + AND suppression.suppressed=1 )) AS undated_reactions FROM corpus_sources source LEFT JOIN conversation_sources ownership ON ownership.source_id=source.id From eb76f2fa6475e96c2f4d52d3a517a9e1cf90d96f Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 22 Aug 2026 18:35:48 -0400 Subject: [PATCH 7/7] fix: make bounded source state converge --- CHANGELOG.md | 5 +- README.md | 5 +- dist/cli.js | 151 +++++++++++++++++----- dist/types.d.ts | 2 + docs/local-message-bundle-v1.md | 10 +- docs/research.md | 11 ++ site/app/page.tsx | 13 +- site/app/readme.generated.ts | 2 +- site/public/og.png | Bin 1013266 -> 935785 bytes src/bundle.ts | 4 +- src/store.test.ts | 218 ++++++++++++++++++++++++++++++-- src/store.ts | 199 +++++++++++++++++++++++++---- src/types.ts | 2 + 13 files changed, 535 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10d8af3..fbd19a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,9 @@ terminal state suppresses evidence, and later reappearance restores it. - Add `sources list` and `sources show` with active message, conversation, reaction, undated-reaction, completeness, and warning health. -- Document Wrench's direct official Beeper CLI path, sequential per-account - progress, retained-shard validation, and atomic seven-file publication. +- Require Wrench 0.13.0 or newer for its direct official Beeper CLI path, + sequential per-account progress, retained-shard validation, and atomic + seven-file publication. - Partition sessions, bursts, and response episodes by conversation, preserve truncated text bubbles as tempo evidence, and count undated reactions without inventing timestamps or exposing raw provider reaction values in aggregate diff --git a/README.md b/README.md index 635b34d..0208ef6 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,9 @@ copy of the database and its transactional sidecars, and opens only that copy with SQLite. It does not change Messages, `chat.db`, or its sidecars. macOS may require permission for the terminal or agent host to read Messages data. -To study accounts connected through Beeper, first ask Wrench to create a new -private Message Like Me bundle: +To study accounts connected through Beeper, install or update to +[Wrench 0.13.0 or newer](https://github.com/hraness/wrench/releases), then ask +it to create a new private Message Like Me bundle: ```sh wrench beeper export-message-like-me \ diff --git a/dist/cli.js b/dist/cli.js index b9cbd04..663bf0e 100755 --- a/dist/cli.js +++ b/dist/cli.js @@ -1658,7 +1658,7 @@ function normalizeBundle(manifest, manifestSha256, records, key) { for (const node of chain) completedEditNodes.add(node); } - const analyzableMessages = accountMessages.filter(({ direction }) => direction !== "unknown").sort((left, right) => compareCodeUnits(left.conversationId, right.conversationId) || compareCodeUnits(left.sortKey, right.sortKey) || compareCodeUnits(left.sentAt, right.sentAt) || compareCodeUnits(left.id, right.id)); + const analyzableMessages = accountMessages.filter(({ direction }) => direction !== "unknown").sort((left, right) => compareCodeUnits(left.conversationId, right.conversationId) || compareCodeUnits(left.sortKey, right.sortKey) || compareCodeUnits(left.sentAt, right.sentAt) || compareCodeUnits(left.provenance.providerId, right.provenance.providerId)); const normalizedMessages = []; const messageProvenance = []; const localMessageIds = new Map; @@ -1687,6 +1687,7 @@ function normalizeBundle(manifest, manifestSha256, records, key) { messageProvenance.push(Object.freeze({ messageId: localId, externalId: message.provenance.providerId, + providerSortKey: message.sortKey, replyToExternalId: message.replyTo?.providerId ?? null, attachments: attachmentProvenance(localId, message.attachments), metadata: message @@ -1748,6 +1749,7 @@ function normalizeBundle(manifest, manifestSha256, records, key) { messageProvenance.push(Object.freeze({ messageId: localId, externalId: timelineCoordinate, + providerSortKey: null, replyToExternalId: reaction.messageProviderId, attachments: Object.freeze([]), metadata: reaction @@ -5172,6 +5174,53 @@ function sourceStateRevision(database, sourceId) { append("suppression", row); return hash.digest("hex"); } +function compareCodeUnits2(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} +function storedProviderSortKey(row) { + const parsed = parsedJson(row.metadata_json, `Message ${row.id} provenance`); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) + return null; + const record = parsed; + const value = "providerSortKey" in record ? record.providerSortKey : record.sortKey; + return typeof value === "string" ? value : null; +} +function rerankBundleMessages(database, sourceId) { + const rows = all(database, ` + SELECT message.id,message.conversation_id,message.sent_at,message.kind, + provenance.external_id,provenance.metadata_json + FROM message_provenance provenance + JOIN messages message ON message.id=provenance.message_id + WHERE provenance.source_id=? + ORDER BY message.conversation_id,message.id + `, sourceId); + const byConversation = new Map; + for (const value of rows) { + const row = Object.freeze({ ...value, provider_sort_key: storedProviderSortKey(value) }); + const values = byConversation.get(row.conversation_id) ?? []; + values.push(row); + byConversation.set(row.conversation_id, values); + } + const update = database.query("UPDATE messages SET source_row_id=? WHERE id=?"); + for (const values of byConversation.values()) { + for (const [index, row] of values.entries()) + update.run(-(index + 1), row.id); + values.sort((left, right) => { + const leftReaction = left.kind === "reaction"; + const rightReaction = right.kind === "reaction"; + if (leftReaction !== rightReaction) + return leftReaction ? 1 : -1; + if (!leftReaction) { + const sort = compareCodeUnits2(left.provider_sort_key ?? left.external_id, right.provider_sort_key ?? right.external_id); + if (sort !== 0) + return sort; + } + return compareCodeUnits2(left.sent_at, right.sent_at) || compareCodeUnits2(left.external_id, right.external_id) || compareCodeUnits2(left.id, right.id); + }); + for (const [index, row] of values.entries()) + update.run(index + 1, row.id); + } +} function setCorpusRevision(database) { const revision = globalCorpusRevision(database); if (revision === null) { @@ -5227,6 +5276,7 @@ function validateSourceSnapshot(snapshot) { externalConversations.add(provenance.externalId); } const messageIds = new Set(snapshot.messages.map(({ id }) => id)); + const messagesById = new Map(snapshot.messages.map((message) => [message.id, message])); if (messageIds.size !== snapshot.messages.length) { throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} repeats message IDs`); } @@ -5240,7 +5290,8 @@ function validateSourceSnapshot(snapshot) { throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has invalid message provenance`); const externalMessages = new Set; for (const provenance of snapshot.messageProvenance) { - if (provenance.externalId.length < 1 || Buffer.byteLength(provenance.externalId, "utf8") > 4096 || externalMessages.has(provenance.externalId) || provenance.attachments.length > 256) + const message = messagesById.get(provenance.messageId); + if (provenance.externalId.length < 1 || Buffer.byteLength(provenance.externalId, "utf8") > 4096 || externalMessages.has(provenance.externalId) || provenance.attachments.length > 256 || provenance.providerSortKey !== null && (provenance.providerSortKey.length < 1 || Buffer.byteLength(provenance.providerSortKey, "utf8") > 1024 || /[\u0000-\u001f\u007f]/u.test(provenance.providerSortKey)) || (snapshot.source.kind === "bundle" ? message.kind === "reaction" === (provenance.providerSortKey !== null) : provenance.providerSortKey !== null)) throw new CliError("invalid-data", `Corpus source ${snapshot.source.id} has invalid external message provenance`); externalMessages.add(provenance.externalId); } @@ -5521,6 +5572,11 @@ class LocalStore { external_id=excluded.external_id,suppressed_at=excluded.suppressed_at, reason=excluded.reason,suppressed=excluded.suppressed `); + const clearExternalSuppression = this.#database.query(` + UPDATE corpus_source_suppressions + SET suppressed_at=?,reason='reappeared',suppressed=0 + WHERE source_id=? AND kind=? AND external_id=? AND suppressed=1 + `); const results = []; let changedAny = false; for (const snapshot of snapshots) { @@ -5576,6 +5632,7 @@ class LocalStore { const provenance = conversationProvenance.get(conversation.id); upsertConversationSource.run(conversation.id, snapshot.source.id, provenance.externalId, canonicalJson(provenance.metadata ?? {})); setSuppression.run(snapshot.source.id, "conversation", conversation.id, provenance.externalId, ingestedAt, "reappeared", 0); + clearExternalSuppression.run(ingestedAt, snapshot.source.id, "conversation", provenance.externalId); } const messageProvenance = new Map(snapshot.messageProvenance.map((value) => [value.messageId, value])); for (const message of snapshot.messages) { @@ -5595,10 +5652,15 @@ class LocalStore { `, message.conversationId)?.value ?? 0) + 1); upsertMessage.run(message.id, sourceRowId, message.sourceGuid, message.conversationId, message.sentAt, message.direction, message.body, message.bodySource, message.kind, message.replyToSourceGuid, message.editedAt, message.retractedAt, message.service, message.attachmentCount); const provenance = messageProvenance.get(message.id); - upsertMessageProvenance.run(message.id, snapshot.source.id, provenance.externalId, provenance.replyToExternalId, canonicalJson(provenance.attachments), canonicalJson(provenance.metadata ?? {})); + upsertMessageProvenance.run(message.id, snapshot.source.id, provenance.externalId, provenance.replyToExternalId, canonicalJson(provenance.attachments), canonicalJson({ + providerSortKey: provenance.providerSortKey, + metadata: provenance.metadata ?? {} + })); setSuppression.run(snapshot.source.id, message.kind === "reaction" ? "reaction" : "message", message.id, provenance.externalId, ingestedAt, "reappeared", 0); + clearExternalSuppression.run(ingestedAt, snapshot.source.id, message.kind === "reaction" ? "reaction" : "message", provenance.externalId); if (message.kind === "reaction") { setSuppression.run(snapshot.source.id, "reaction-timeline", message.id, provenance.externalId, ingestedAt, "reappeared", 0); + clearExternalSuppression.run(ingestedAt, snapshot.source.id, "reaction-timeline", provenance.externalId); } } for (const reaction of snapshot.reactionFacts ?? []) { @@ -5614,6 +5676,8 @@ class LocalStore { upsertReactionFact.run(reaction.id, snapshot.source.id, reaction.externalId, reaction.targetExternalId, conversationId, reaction.direction, reaction.body, reaction.reactedAt, reaction.state); if (reaction.state === "active") { setSuppression.run(snapshot.source.id, "reaction", reaction.id, reaction.externalId, ingestedAt, "reappeared", 0); + clearExternalSuppression.run(ingestedAt, snapshot.source.id, "reaction", reaction.externalId); + clearExternalSuppression.run(ingestedAt, snapshot.source.id, "reaction-timeline", reaction.externalId); } } this.#database.query(` @@ -5697,6 +5761,9 @@ class LocalStore { } setSuppression.run(snapshot.source.id, deletion.entityKind, localId ?? `external:${deletion.externalId}`, deletion.externalId, deletion.deletedAt, deletion.reason ?? "tombstone", 1); } + if (snapshot.source.kind === "bundle") { + rerankBundleMessages(this.#database, snapshot.source.id); + } const stateRevision = sourceStateRevision(this.#database, snapshot.source.id); this.#database.query("UPDATE corpus_sources SET revision=? WHERE id=?").run(stateRevision, snapshot.source.id); const changed = existing?.revision !== stateRevision; @@ -5767,6 +5834,7 @@ class LocalStore { messageProvenance: Object.freeze(snapshot.messages.map((message) => ({ messageId: message.id, externalId: message.sourceGuid, + providerSortKey: null, replyToExternalId: message.replyToSourceGuid, attachments: Object.freeze(Array.from({ length: message.attachmentCount }, (_value, index) => ({ id: `unavailable-${index + 1}`, @@ -5802,33 +5870,56 @@ class LocalStore { SELECT source.*, count(distinct ownership.conversation_id) AS conversations, count(message.id) AS messages, - (SELECT count(*) FROM corpus_reaction_facts reaction - WHERE reaction.source_id=source.id AND reaction.state='active' - AND NOT EXISTS ( - SELECT 1 FROM corpus_source_suppressions suppression - WHERE suppression.source_id=source.id AND suppression.kind='reaction' - AND suppression.local_id=reaction.id AND suppression.suppressed=1 - ) - AND NOT EXISTS ( - SELECT 1 FROM corpus_source_suppressions suppression - WHERE suppression.source_id=source.id AND suppression.kind='conversation' - AND suppression.local_id=reaction.conversation_id - AND suppression.suppressed=1 - )) AS reactions, - (SELECT count(*) FROM corpus_reaction_facts reaction - WHERE reaction.source_id=source.id AND reaction.state='active' - AND reaction.reacted_at IS NULL - AND NOT EXISTS ( - SELECT 1 FROM corpus_source_suppressions suppression - WHERE suppression.source_id=source.id AND suppression.kind='reaction' - AND suppression.local_id=reaction.id AND suppression.suppressed=1 - ) - AND NOT EXISTS ( - SELECT 1 FROM corpus_source_suppressions suppression - WHERE suppression.source_id=source.id AND suppression.kind='conversation' - AND suppression.local_id=reaction.conversation_id - AND suppression.suppressed=1 - )) AS undated_reactions + CASE source.kind WHEN 'bundle' THEN + (SELECT count(*) FROM corpus_reaction_facts reaction + WHERE reaction.source_id=source.id AND reaction.state='active' + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id AND suppression.kind='reaction' + AND suppression.local_id=reaction.id AND suppression.suppressed=1 + ) + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id AND suppression.kind='conversation' + AND suppression.local_id=reaction.conversation_id + AND suppression.suppressed=1 + )) + ELSE + (SELECT count(*) FROM messages reaction_message + JOIN message_provenance reaction_provenance + ON reaction_provenance.message_id=reaction_message.id + WHERE reaction_provenance.source_id=source.id + AND reaction_message.kind='reaction' AND reaction_message.retracted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id + AND suppression.kind IN ('message','reaction','reaction-timeline') + AND suppression.local_id=reaction_message.id AND suppression.suppressed=1 + ) + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id AND suppression.kind='conversation' + AND suppression.local_id=reaction_message.conversation_id + AND suppression.suppressed=1 + )) + END AS reactions, + CASE source.kind WHEN 'bundle' THEN + (SELECT count(*) FROM corpus_reaction_facts reaction + WHERE reaction.source_id=source.id AND reaction.state='active' + AND reaction.reacted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id AND suppression.kind='reaction' + AND suppression.local_id=reaction.id AND suppression.suppressed=1 + ) + AND NOT EXISTS ( + SELECT 1 FROM corpus_source_suppressions suppression + WHERE suppression.source_id=source.id AND suppression.kind='conversation' + AND suppression.local_id=reaction.conversation_id + AND suppression.suppressed=1 + )) + ELSE 0 + END AS undated_reactions FROM corpus_sources source LEFT JOIN conversation_sources ownership ON ownership.source_id=source.id AND NOT EXISTS ( diff --git a/dist/types.d.ts b/dist/types.d.ts index 3415200..75ea861 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -97,6 +97,8 @@ export type CorpusAttachmentProvenance = Readonly<{ export type CorpusMessageProvenance = Readonly<{ messageId: string; externalId: string; + /** Provider ordering coordinate. Null when this record has no such coordinate. */ + providerSortKey: string | null; replyToExternalId: string | null; attachments: readonly CorpusAttachmentProvenance[]; metadata?: unknown; diff --git a/docs/local-message-bundle-v1.md b/docs/local-message-bundle-v1.md index 99309d6..66a4e14 100644 --- a/docs/local-message-bundle-v1.md +++ b/docs/local-message-bundle-v1.md @@ -6,7 +6,8 @@ provider capture from analysis: a producer handles provider access and writes the bundle, while `messagelikeme ingest bundle` verifies and normalizes it. The importer never receives provider credentials and never calls the producer. -The current producer is Wrench's local Beeper export: +The current producer is the local Beeper export in +[Wrench 0.13.0 or newer](https://github.com/hraness/wrench/releases): ```sh wrench beeper export-message-like-me \ @@ -140,7 +141,8 @@ matching. It never uses an incomplete roster for that join. `sentAt` is the message's actual temporal coordinate. `sortKey` is an opaque provider ordering key. Within one account and conversation, Message Like Me -orders lexical `sortKey`, then `sentAt` and ID as deterministic tie-breakers. +orders lexical `sortKey`, then `sentAt` and stable provider ID as deterministic +tie-breakers. `bodyTruncated: true` means the body cannot be prose evidence. The record still becomes a text bubble for tempo, reply, and delivery-shape analysis. A message @@ -194,7 +196,9 @@ None is authoritative for deletion by absence. Reimport therefore upserts present records and retains prior records omitted by a later bundle. Explicit message deletion, removed reaction state, replacement edges, and tombstones are applied separately. A valid later reappearance clears the matching -suppression. +suppression. Present and retained messages are reranked together by the provider +ordering coordinates, so a bounded backfill converges with a fresh import of +the same final records. The manifest completeness kind and reason apply conservatively to every account. Stored `observedFrom` and `observedTo` bounds are derived from the diff --git a/docs/research.md b/docs/research.md index 6927b75..9df965c 100644 --- a/docs/research.md +++ b/docs/research.md @@ -26,6 +26,11 @@ context-specific adjustments. Message Like Me therefore treats incoming messages as response context and only the user's outgoing messages as evidence of the user's prose. +[Catch Me If You Can? Not Yet](https://aclanthology.org/2025.findings-emnlp.532/) +evaluates nuanced individual style in informal communication, a task close to +private messaging. Its scope reinforces the same boundary: measured tendencies +can guide a draft without establishing a faithful digital copy of its author. + [LaMP](https://aclanthology.org/2024.acl-long.399/) evaluated personalized classification and generation from user histories and found retrieval-based personalization useful across most of its tasks. Its experiments included @@ -48,6 +53,12 @@ content and writing style separately. Its reported agreement with human judgment improved over the comparison methods in that study. It does not measure message timing, bubble boundaries, or reply-link behavior. +[Can You Make It Sound Like You?](https://aclanthology.org/2026.acl-long.2030/) +studies personalized writing through human review and post-editing. That +workflow supports Message Like Me's product boundary: the output is an unsent +candidate for the user to inspect and revise, not an autonomous act on the +user's behalf. + [Münker, Schwager, and Rettinger](https://arxiv.org/abs/2506.21974) tested LLM-based imitation of social-network communication and argue that a simulation must be validated for empirical realism in the setting where it diff --git a/site/app/page.tsx b/site/app/page.tsx index ee0d3ef..8f697a3 100644 --- a/site/app/page.tsx +++ b/site/app/page.tsx @@ -59,11 +59,11 @@ export default function Home() {

- $ wrench beeper export-message-like-me --auth beeper-main --output /private/export + $ wrench beeper export-message-like-me --auth beeper-main --output "$HOME/message-like-me-beeper"

-

✓ private source bundle written

+

✓ Wrench 0.13.0+ private bundle written

- $ messagelikeme ingest bundle --input /private/export + $ messagelikeme ingest bundle --input "$HOME/message-like-me-beeper"

✓ source-aware history merged

@@ -87,9 +87,10 @@ export default function Home() {

Read stable local copies.

Import Messages and optional Contacts directly, or merge a - private source-aware bundle exported by Wrench from Beeper. + private source-aware bundle exported by Wrench 0.13.0+ from + Beeper.

- messagelikeme ingest bundle --input /private/export + messagelikeme ingest bundle --input "$HOME/message-like-me-beeper"

02 / understand

@@ -178,7 +179,7 @@ export default function Home() {

1bun add --global github:hraness/message-like-me#v0.3.0

2messagelikeme skill install

3messagelikeme ingest imessage

-

4messagelikeme ingest bundle --input /private/export

+

4messagelikeme ingest bundle --input "$HOME/message-like-me-beeper"

diff --git a/site/app/readme.generated.ts b/site/app/readme.generated.ts index 4d45c49..e789cba 100644 --- a/site/app/readme.generated.ts +++ b/site/app/readme.generated.ts @@ -1,2 +1,2 @@ // Generated from ../README.md by scripts/sync-readme.ts. -export const readmeHtml = "

Message Like Me

\n

A local-first CLI and Agent Skill for studying private messaging history and\ndrafting messages that sound like you.

\n

Message Like Me turns private local messaging history into deterministic\nconversation metrics, bounded study packets, and reusable style profiles. It\nreads native iMessage history and strict local source bundles, including\nmulti-account Beeper exports produced through Wrench. Its Agent Skill teaches\nCodex, Claude, and other coding agents how to interpret those local artifacts\nand draft unsent replies in your voice.

\n

The CLI does not call an AI service, authenticate with a product account, send\nmessages, or operate Messages. The agent already running the skill supplies the\nsemantic analysis and drafting judgment.

\n

This is an evidence layer for relationship-aware drafting, not a digital clone.\nIt does not train a model, represent your identity, infer your beliefs, or claim\nthat a draft is what you would have written. Your current meaning, facts, and\nintent outrank historical style.

\n

Install

\n

Message Like Me requires Bun 1.3.14 or newer. Install the immutable public\nrelease from GitHub, then install the Agent Skill:

\n
bun add --global github:hraness/message-like-me#v0.3.0\nmessagelikeme skill install\n
\n

Start a new agent session after installing the skill. The default target is\nCodex at user scope. Other supported targets and project-local installation are\navailable explicitly:

\n
messagelikeme skill install --target claude\nmessagelikeme skill install --target agents --scope project\nmessagelikeme skill path\n
\n

Message Like Me is distributed directly through GitHub and is not published to\nnpm.

\n

Start with private local history

\n

Initialize the private data store and inspect its location:

\n
messagelikeme init\nmessagelikeme doctor --json\n
\n

On macOS, the default store is:

\n
~/Library/Application Support/Message Like Me/\n
\n

The directory is private to the current user. It contains a local SQLite\ndatabase, stored profiles, and a private installation key used to derive\nstable pseudonymous IDs. Study packets are written only to the explicit path\nyou choose. You can put the store elsewhere by placing\n--data-dir /absolute/private/path before the command.

\n

Import the current user's iMessage database:

\n
messagelikeme ingest imessage --json\n
\n

The default source is the current user's Messages chat.db. Use --database\nonly to name another caller-owned physical database:

\n
messagelikeme ingest imessage --database /absolute/path/to/chat.db --json\n
\n

Ingestion validates the source schema and ownership, makes a stable private\ncopy of the database and its transactional sidecars, and opens only that copy\nwith SQLite. It does not change Messages, chat.db, or its sidecars. macOS may\nrequire permission for the terminal or agent host to read Messages data.

\n

To study accounts connected through Beeper, first ask Wrench to create a new\nprivate Message Like Me bundle:

\n
wrench beeper export-message-like-me \\\n  --auth <beeper-auth-id> \\\n  --output /absolute/private/path/beeper-bundle \\\n  --json\n
\n

The optional --limit-chats, --limit-messages, and --max-participants\nflags lower the export bounds. The output path must be a normalized absolute\npath to a directory that does not already exist. Wrench calls the pinned\nofficial Beeper CLI directly. It enumerates\nthe connected account realm, invokes export --no-attachments once per\naccount in deterministic order, and reports the account ordinal, elapsed-time\nheartbeats, and cumulative validated chat and message counts on stderr. It\nretains each private raw shard until it can atomically publish the complete\nmode-0700 seven-file bundle with mode-0600 files.

\n

The export does not use the separate\nBeeper Desktop API MCP project.\nThe CLI path supplies the bounded account snapshots and local files needed for\nhash validation, deterministic conversion, crash recovery, and atomic\npublication. Provider URLs and credentials are excluded. Message Like Me does\nnot receive the Beeper credential and does not call Beeper or Wrench itself.

\n

Ingest the finished directory, then inspect its redacted source health:

\n
messagelikeme ingest bundle --input /absolute/private/path/beeper-bundle --json\nmessagelikeme sources list --json\nmessagelikeme sources show <source-id> --json\n
\n

The importer verifies the fixed version-one inventory, canonical UTF-8 NDJSON,\nrecord and byte bounds, owner-only permissions, artifact digests, and manifest\ndigest before changing the store. One bundle may contain several connected\naccounts and networks; each becomes a separate source namespace. Native\niMessage and prior bundle sources remain alongside it.

\n

The complete interchange, integrity, identity, and reimport laws are in the\nversion-one local message bundle contract.

\n

Beeper exports describe bounded local observations. A later bounded export\nthat omits an older record does not delete retained history. Explicit deletion,\nremoval, replacement, and tombstone records suppress their target, and a later\nreappearance restores it. Older snapshots cannot overwrite newer state. Use\nsources show <source-id> --private --json only when you deliberately need the\nprivate provider account and source metadata.

\n

Optionally enrich and join direct conversations with private identities from\nmacOS Contacts:

\n
messagelikeme ingest contacts --json\n
\n

The default source is the current user's AddressBook directory. An explicit\nabsolute AddressBook root, Sources directory, store directory, or\nAddressBook-vN.abcddb file can be selected with --addressbook:

\n
messagelikeme ingest contacts \\\n  --addressbook /absolute/path/to/AddressBook \\\n  --json\n
\n

Contacts ingest may run before or after any message source. It reads only\nbounded name, email, and phone fields from a stable private copy. Exact\nnormalized email or E.164 phone handles can join several one-to-one threads\nfor the same AddressBook person into one analysis scope. A bundle conversation\nis eligible only when the producer positively marks its direct participant\nroster complete. Existing conversation IDs remain aliases for that person\nscope. Shared handles remain ambiguous, local phone numbers never gain a\nguessed country code, unmatched threads stay separate, and groups are never\ncollapsed to one person. Contact labels have their own revision, so a rename\ndoes not stale a messaging-style profile. messagelikeme doctor reports local\naggregate state without asking for an account or credential.

\n

Inspect behavior without exposing prose

\n

Contact listings and aggregate views omit private labels, handles, and message\nbodies by default:

\n
messagelikeme contacts list --min-outgoing 20 --json\nmessagelikeme contacts show <contact-id> --json\nmessagelikeme inspect tempo <contact-id> --session-gap 28800 --burst-gap 300 --json\nmessagelikeme inspect sessions <contact-id> --limit 20 --json\n
\n

The metrics cover conversation start and end, message counts, incoming and\noutgoing turns, within-session response latency, single-message versus\nmulti-message replies, surface prose features, multi-point response contexts,\nreactions, and explicit reply use. Incoming messages establish what you were\nresponding to; they are never counted as examples of your writing style.\nSessions, bursts, and response episodes never cross a source conversation\nboundary. Person scopes spanning several apps expose a sorted services\nbreakdown instead of hiding the mixed-channel evidence behind a null service.\nReactions with no provider timestamp still contribute to reaction counts and\ndirection, but never to temporal metrics. Raw provider reaction values remain\nprivate; ordinary metrics and drafting context expose only fixed-size counts,\ndirection, datedness, and the outgoing reaction ratio. Session and burst gaps\nare configurable seconds and are recorded with each result. They are\nsegmentation choices, not universal facts about conversation.

\n

Pass --private to contacts list or contacts show only when you need to\nresolve a pseudonymous contact to its local private label or participants.

\n

When you already know the complete Contacts label, resolve only that exact\nprivate name instead of listing every label:

\n
messagelikeme contacts resolve "Exact Contact Name" --private --json\n
\n

Resolution is normalized for case and Unicode representation, but it does not\nperform prefix, substring, phonetic, or fuzzy matching. It returns only direct\nperson scopes and labels, never handles or message bodies.

\n

Build a style profile

\n

Aggregate metrics cannot explain why a short burst works in one context or why\na longer single message appears in another. For that semantic work, prepare a\nsmall, diverse study packet at an explicit private path:

\n
messagelikeme study prepare <contact-id> \\\n  --output /absolute/private/path/study.json \\\n  --before 2026-08-01T00:00:00.000Z \\\n  --limit 24 \\\n  --json\n
\n

study prepare and evaluate prepare are the only commands that write bounded\nmessage bodies outside the private database. Their outputs are mode 0600.\nA study packet contains incoming context and outgoing responses selected across\ndifferent response shapes; it is not a full transcript export. By default,\neach body is capped at 4 KiB, each example keeps at most 12 text messages per\ndirection, and the entire packet keeps at most 256 KiB of body text. Packet\ncoverage fields report every truncation or omission explicitly.

\n

Keep the JSON receipt with the analysis. Its packetSha256 binds the finished\nprofile to these exact packet bytes; the packet does not contain its own digest.

\n

--after is inclusive and --before is exclusive. Temporal bounds let you\nreserve later conversations for evaluation. Invoke $message-like-me in your\nagent and ask it to analyze that contact. The skill separates measured facts\nfrom inferred patterns, covers prose and tempo, studies how several inbound\npoints are handled, and treats reply links and tapbacks separately from written\ntext.

\n

The agent writes a schema-version-two profile and asks the CLI to validate and\nstore it:

\n
messagelikeme profile apply /absolute/private/path/profile.json --json\nmessagelikeme profile show <contact-id> --json\n
\n

A version-two profile records the global corpus revision for provenance, a\nperson-and-window-specific evidence revision for validity, the exact\nstudy-packet SHA-256, and the packet's non-body evidence manifest. Measured and\ninferred claims cite valid packet example IDs and record counterexamples,\nsupport counts, confidence, and drafting consequences. Messages for someone\nelse or outside the studied time window do not stale it; changes inside its\nactual evidence do.

\n

Export a profile only when you need an explicit private copy:

\n
messagelikeme profile export <contact-id> --output /absolute/private/path/profile.json\n
\n

Version-one profiles remain readable for migration, but new analyses should use\nschema/style-profile-v2.schema.json.

\n

Audit against later conversations

\n

Prepare a separate prompt and reference set from conversations after the study\ncutoff:

\n
messagelikeme evaluate prepare <contact-id> \\\n  --after 2026-08-01T00:00:00.000Z \\\n  --prompt-output /absolute/private/path/evaluation-prompts.json \\\n  --reference-output /absolute/private/path/evaluation-references.json \\\n  --json\n
\n

Give the agent only the prompt file and fix one candidate bubble sequence per\ncase before opening the reference file. Then compare intent coverage, factual\nmeaning, prose, bubble shape, explicit replies, privacy leakage, and\ncalibration. The files support a blind workflow but do not enforce one, and the\nhistorical response is one observation rather than a unique correct answer.\nThe CLI deliberately does not collapse these dimensions into a universal\nfidelity score. See the methodology.

\n

Draft an unsent reply

\n

Ask an agent with the installed $message-like-me skill to draft for a\npseudonymous contact. The compact deterministic context is available through:

\n
messagelikeme context <contact-id> --json\n
\n

The skill preserves your intended meaning, selects the applicable profile,\nand can express the result as one message or a realistic sequence of separate\nbubbles. It uses explicit replies only when your evidence and the current\ncontext support them.

\n

Drafting ends with text in the agent task. Message Like Me has no send, react,\nschedule, or messaging-application command.

\n

Command reference

\n

Run messagelikeme --help for the checked grammar. The public surfaces are:

\n
messagelikeme init [--json]\nmessagelikeme ingest imessage [--database PATH] [--json]\nmessagelikeme ingest contacts [--addressbook PATH] [--json]\nmessagelikeme ingest bundle --input ABS_PATH [--json]\nmessagelikeme sources list [--private] [--json]\nmessagelikeme sources show SOURCE_ID [--private] [--json]\nmessagelikeme contacts list [--min-outgoing N] [--limit N] [--private] [--json]\nmessagelikeme contacts show CONTACT_ID [--private] [--json]\nmessagelikeme contacts resolve QUERY --private [--limit N] [--json]\nmessagelikeme inspect tempo CONTACT_ID [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme inspect sessions CONTACT_ID [--limit N] [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme study prepare CONTACT_ID --output FILE [--limit N]\n  [--after ISO_TIMESTAMP] [--before ISO_TIMESTAMP]\n  [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme evaluate prepare CONTACT_ID --after ISO_TIMESTAMP\n  --prompt-output FILE --reference-output FILE [--before ISO_TIMESTAMP]\n  [--limit N] [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme profile apply FILE [--json]\nmessagelikeme profile show CONTACT_ID [--json]\nmessagelikeme profile export CONTACT_ID --output FILE [--json]\nmessagelikeme context CONTACT_ID [--json]\nmessagelikeme skill path [--json]\nmessagelikeme skill install [--target codex|claude|agents]\n  [--scope user|project] [--project PATH] [--force] [--json]\nmessagelikeme doctor [--json]\n
\n

Place global --data-dir PATH before the command.

\n

Privacy model

\n
    \n
  • The original chat.db and AddressBook databases remain authoritative.\nSQLite opens only stable private copies, never the source files or sidecars.
  • \n
  • Source bundles remain private caller-owned inputs. Import verifies their\nfixed inventory, canonical bytes, digests, bounds, and owner-only modes.
  • \n
  • The normalized corpus, profiles, and installation key stay in a private local\nstore with owner-only permissions.
  • \n
  • Stable source, contact, participant, conversation, message, and reaction IDs\nare derived with a private per-install HMAC key. Pseudonymous IDs are not\nencryption.
  • \n
  • Aggregate commands omit bodies and private labels. Study and evaluation\npackets are bounded, explicit body-bearing exports.
  • \n
  • Message text never goes to a Message Like Me server. There is no service,\naccount, auth flow, analytics client, or network-backed model call.
  • \n
  • Opening a study packet makes its bounded excerpts visible to the agent\nenvironment already running the skill. Use an agent environment whose data\nhandling you accept; the CLI cannot make a hosted agent local.
  • \n
  • Public fixtures are synthetic. Private corpora, profiles, packets, and drafts\ndo not belong in Git, issues, logs, packages, or examples.
  • \n
  • A draft is never sent.
  • \n
\n

Read SECURITY.md before integrating the library into another\ntool or handling a private packet outside the CLI. The\nmethodology defines every unit and evidence boundary;\nthe research review documents papers, neighboring OSS, and\nthe claims this project does not make.

\n

TypeScript library

\n

The package exports the versioned corpus, metrics, study-packet, and profile\ntypes plus deterministic canonical JSON and SHA-256 helpers:

\n
import type { ContactMetrics, StyleProfileV2 } from "@hraness/message-like-me"\nimport { canonicalJson, sha256 } from "@hraness/message-like-me"\n
\n

The library does not start the CLI, inspect Messages or Contacts, connect to a\nnetwork, or send a draft merely because it is imported.

\n

Development

\n
bun install --frozen-lockfile --ignore-scripts\nbun run check\n
\n

Tests use synthetic Messages and AddressBook databases plus synthetic source\nbundles and conversations. Never add a real message, handle, group title,\nattachment, contact record, private path, or derived profile to a fixture.

\n

The canonical repository is\nhraness/message-like-me.\nThe informational project page is\nmessagelikeme.com. The CLI does not connect to\nthe site, and the site never receives message or contact data.

\n

License

\n

MIT.

\n"; +export const readmeHtml = "

Message Like Me

\n

A local-first CLI and Agent Skill for studying private messaging history and\ndrafting messages that sound like you.

\n

Message Like Me turns private local messaging history into deterministic\nconversation metrics, bounded study packets, and reusable style profiles. It\nreads native iMessage history and strict local source bundles, including\nmulti-account Beeper exports produced through Wrench. Its Agent Skill teaches\nCodex, Claude, and other coding agents how to interpret those local artifacts\nand draft unsent replies in your voice.

\n

The CLI does not call an AI service, authenticate with a product account, send\nmessages, or operate Messages. The agent already running the skill supplies the\nsemantic analysis and drafting judgment.

\n

This is an evidence layer for relationship-aware drafting, not a digital clone.\nIt does not train a model, represent your identity, infer your beliefs, or claim\nthat a draft is what you would have written. Your current meaning, facts, and\nintent outrank historical style.

\n

Install

\n

Message Like Me requires Bun 1.3.14 or newer. Install the immutable public\nrelease from GitHub, then install the Agent Skill:

\n
bun add --global github:hraness/message-like-me#v0.3.0\nmessagelikeme skill install\n
\n

Start a new agent session after installing the skill. The default target is\nCodex at user scope. Other supported targets and project-local installation are\navailable explicitly:

\n
messagelikeme skill install --target claude\nmessagelikeme skill install --target agents --scope project\nmessagelikeme skill path\n
\n

Message Like Me is distributed directly through GitHub and is not published to\nnpm.

\n

Start with private local history

\n

Initialize the private data store and inspect its location:

\n
messagelikeme init\nmessagelikeme doctor --json\n
\n

On macOS, the default store is:

\n
~/Library/Application Support/Message Like Me/\n
\n

The directory is private to the current user. It contains a local SQLite\ndatabase, stored profiles, and a private installation key used to derive\nstable pseudonymous IDs. Study packets are written only to the explicit path\nyou choose. You can put the store elsewhere by placing\n--data-dir /absolute/private/path before the command.

\n

Import the current user's iMessage database:

\n
messagelikeme ingest imessage --json\n
\n

The default source is the current user's Messages chat.db. Use --database\nonly to name another caller-owned physical database:

\n
messagelikeme ingest imessage --database /absolute/path/to/chat.db --json\n
\n

Ingestion validates the source schema and ownership, makes a stable private\ncopy of the database and its transactional sidecars, and opens only that copy\nwith SQLite. It does not change Messages, chat.db, or its sidecars. macOS may\nrequire permission for the terminal or agent host to read Messages data.

\n

To study accounts connected through Beeper, install or update to\nWrench 0.13.0 or newer, then ask\nit to create a new private Message Like Me bundle:

\n
wrench beeper export-message-like-me \\\n  --auth <beeper-auth-id> \\\n  --output /absolute/private/path/beeper-bundle \\\n  --json\n
\n

The optional --limit-chats, --limit-messages, and --max-participants\nflags lower the export bounds. The output path must be a normalized absolute\npath to a directory that does not already exist. Wrench calls the pinned\nofficial Beeper CLI directly. It enumerates\nthe connected account realm, invokes export --no-attachments once per\naccount in deterministic order, and reports the account ordinal, elapsed-time\nheartbeats, and cumulative validated chat and message counts on stderr. It\nretains each private raw shard until it can atomically publish the complete\nmode-0700 seven-file bundle with mode-0600 files.

\n

The export does not use the separate\nBeeper Desktop API MCP project.\nThe CLI path supplies the bounded account snapshots and local files needed for\nhash validation, deterministic conversion, crash recovery, and atomic\npublication. Provider URLs and credentials are excluded. Message Like Me does\nnot receive the Beeper credential and does not call Beeper or Wrench itself.

\n

Ingest the finished directory, then inspect its redacted source health:

\n
messagelikeme ingest bundle --input /absolute/private/path/beeper-bundle --json\nmessagelikeme sources list --json\nmessagelikeme sources show <source-id> --json\n
\n

The importer verifies the fixed version-one inventory, canonical UTF-8 NDJSON,\nrecord and byte bounds, owner-only permissions, artifact digests, and manifest\ndigest before changing the store. One bundle may contain several connected\naccounts and networks; each becomes a separate source namespace. Native\niMessage and prior bundle sources remain alongside it.

\n

The complete interchange, integrity, identity, and reimport laws are in the\nversion-one local message bundle contract.

\n

Beeper exports describe bounded local observations. A later bounded export\nthat omits an older record does not delete retained history. Explicit deletion,\nremoval, replacement, and tombstone records suppress their target, and a later\nreappearance restores it. Older snapshots cannot overwrite newer state. Use\nsources show <source-id> --private --json only when you deliberately need the\nprivate provider account and source metadata.

\n

Optionally enrich and join direct conversations with private identities from\nmacOS Contacts:

\n
messagelikeme ingest contacts --json\n
\n

The default source is the current user's AddressBook directory. An explicit\nabsolute AddressBook root, Sources directory, store directory, or\nAddressBook-vN.abcddb file can be selected with --addressbook:

\n
messagelikeme ingest contacts \\\n  --addressbook /absolute/path/to/AddressBook \\\n  --json\n
\n

Contacts ingest may run before or after any message source. It reads only\nbounded name, email, and phone fields from a stable private copy. Exact\nnormalized email or E.164 phone handles can join several one-to-one threads\nfor the same AddressBook person into one analysis scope. A bundle conversation\nis eligible only when the producer positively marks its direct participant\nroster complete. Existing conversation IDs remain aliases for that person\nscope. Shared handles remain ambiguous, local phone numbers never gain a\nguessed country code, unmatched threads stay separate, and groups are never\ncollapsed to one person. Contact labels have their own revision, so a rename\ndoes not stale a messaging-style profile. messagelikeme doctor reports local\naggregate state without asking for an account or credential.

\n

Inspect behavior without exposing prose

\n

Contact listings and aggregate views omit private labels, handles, and message\nbodies by default:

\n
messagelikeme contacts list --min-outgoing 20 --json\nmessagelikeme contacts show <contact-id> --json\nmessagelikeme inspect tempo <contact-id> --session-gap 28800 --burst-gap 300 --json\nmessagelikeme inspect sessions <contact-id> --limit 20 --json\n
\n

The metrics cover conversation start and end, message counts, incoming and\noutgoing turns, within-session response latency, single-message versus\nmulti-message replies, surface prose features, multi-point response contexts,\nreactions, and explicit reply use. Incoming messages establish what you were\nresponding to; they are never counted as examples of your writing style.\nSessions, bursts, and response episodes never cross a source conversation\nboundary. Person scopes spanning several apps expose a sorted services\nbreakdown instead of hiding the mixed-channel evidence behind a null service.\nReactions with no provider timestamp still contribute to reaction counts and\ndirection, but never to temporal metrics. Raw provider reaction values remain\nprivate; ordinary metrics and drafting context expose only fixed-size counts,\ndirection, datedness, and the outgoing reaction ratio. Session and burst gaps\nare configurable seconds and are recorded with each result. They are\nsegmentation choices, not universal facts about conversation.

\n

Pass --private to contacts list or contacts show only when you need to\nresolve a pseudonymous contact to its local private label or participants.

\n

When you already know the complete Contacts label, resolve only that exact\nprivate name instead of listing every label:

\n
messagelikeme contacts resolve "Exact Contact Name" --private --json\n
\n

Resolution is normalized for case and Unicode representation, but it does not\nperform prefix, substring, phonetic, or fuzzy matching. It returns only direct\nperson scopes and labels, never handles or message bodies.

\n

Build a style profile

\n

Aggregate metrics cannot explain why a short burst works in one context or why\na longer single message appears in another. For that semantic work, prepare a\nsmall, diverse study packet at an explicit private path:

\n
messagelikeme study prepare <contact-id> \\\n  --output /absolute/private/path/study.json \\\n  --before 2026-08-01T00:00:00.000Z \\\n  --limit 24 \\\n  --json\n
\n

study prepare and evaluate prepare are the only commands that write bounded\nmessage bodies outside the private database. Their outputs are mode 0600.\nA study packet contains incoming context and outgoing responses selected across\ndifferent response shapes; it is not a full transcript export. By default,\neach body is capped at 4 KiB, each example keeps at most 12 text messages per\ndirection, and the entire packet keeps at most 256 KiB of body text. Packet\ncoverage fields report every truncation or omission explicitly.

\n

Keep the JSON receipt with the analysis. Its packetSha256 binds the finished\nprofile to these exact packet bytes; the packet does not contain its own digest.

\n

--after is inclusive and --before is exclusive. Temporal bounds let you\nreserve later conversations for evaluation. Invoke $message-like-me in your\nagent and ask it to analyze that contact. The skill separates measured facts\nfrom inferred patterns, covers prose and tempo, studies how several inbound\npoints are handled, and treats reply links and tapbacks separately from written\ntext.

\n

The agent writes a schema-version-two profile and asks the CLI to validate and\nstore it:

\n
messagelikeme profile apply /absolute/private/path/profile.json --json\nmessagelikeme profile show <contact-id> --json\n
\n

A version-two profile records the global corpus revision for provenance, a\nperson-and-window-specific evidence revision for validity, the exact\nstudy-packet SHA-256, and the packet's non-body evidence manifest. Measured and\ninferred claims cite valid packet example IDs and record counterexamples,\nsupport counts, confidence, and drafting consequences. Messages for someone\nelse or outside the studied time window do not stale it; changes inside its\nactual evidence do.

\n

Export a profile only when you need an explicit private copy:

\n
messagelikeme profile export <contact-id> --output /absolute/private/path/profile.json\n
\n

Version-one profiles remain readable for migration, but new analyses should use\nschema/style-profile-v2.schema.json.

\n

Audit against later conversations

\n

Prepare a separate prompt and reference set from conversations after the study\ncutoff:

\n
messagelikeme evaluate prepare <contact-id> \\\n  --after 2026-08-01T00:00:00.000Z \\\n  --prompt-output /absolute/private/path/evaluation-prompts.json \\\n  --reference-output /absolute/private/path/evaluation-references.json \\\n  --json\n
\n

Give the agent only the prompt file and fix one candidate bubble sequence per\ncase before opening the reference file. Then compare intent coverage, factual\nmeaning, prose, bubble shape, explicit replies, privacy leakage, and\ncalibration. The files support a blind workflow but do not enforce one, and the\nhistorical response is one observation rather than a unique correct answer.\nThe CLI deliberately does not collapse these dimensions into a universal\nfidelity score. See the methodology.

\n

Draft an unsent reply

\n

Ask an agent with the installed $message-like-me skill to draft for a\npseudonymous contact. The compact deterministic context is available through:

\n
messagelikeme context <contact-id> --json\n
\n

The skill preserves your intended meaning, selects the applicable profile,\nand can express the result as one message or a realistic sequence of separate\nbubbles. It uses explicit replies only when your evidence and the current\ncontext support them.

\n

Drafting ends with text in the agent task. Message Like Me has no send, react,\nschedule, or messaging-application command.

\n

Command reference

\n

Run messagelikeme --help for the checked grammar. The public surfaces are:

\n
messagelikeme init [--json]\nmessagelikeme ingest imessage [--database PATH] [--json]\nmessagelikeme ingest contacts [--addressbook PATH] [--json]\nmessagelikeme ingest bundle --input ABS_PATH [--json]\nmessagelikeme sources list [--private] [--json]\nmessagelikeme sources show SOURCE_ID [--private] [--json]\nmessagelikeme contacts list [--min-outgoing N] [--limit N] [--private] [--json]\nmessagelikeme contacts show CONTACT_ID [--private] [--json]\nmessagelikeme contacts resolve QUERY --private [--limit N] [--json]\nmessagelikeme inspect tempo CONTACT_ID [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme inspect sessions CONTACT_ID [--limit N] [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme study prepare CONTACT_ID --output FILE [--limit N]\n  [--after ISO_TIMESTAMP] [--before ISO_TIMESTAMP]\n  [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme evaluate prepare CONTACT_ID --after ISO_TIMESTAMP\n  --prompt-output FILE --reference-output FILE [--before ISO_TIMESTAMP]\n  [--limit N] [--session-gap N] [--burst-gap N] [--json]\nmessagelikeme profile apply FILE [--json]\nmessagelikeme profile show CONTACT_ID [--json]\nmessagelikeme profile export CONTACT_ID --output FILE [--json]\nmessagelikeme context CONTACT_ID [--json]\nmessagelikeme skill path [--json]\nmessagelikeme skill install [--target codex|claude|agents]\n  [--scope user|project] [--project PATH] [--force] [--json]\nmessagelikeme doctor [--json]\n
\n

Place global --data-dir PATH before the command.

\n

Privacy model

\n
    \n
  • The original chat.db and AddressBook databases remain authoritative.\nSQLite opens only stable private copies, never the source files or sidecars.
  • \n
  • Source bundles remain private caller-owned inputs. Import verifies their\nfixed inventory, canonical bytes, digests, bounds, and owner-only modes.
  • \n
  • The normalized corpus, profiles, and installation key stay in a private local\nstore with owner-only permissions.
  • \n
  • Stable source, contact, participant, conversation, message, and reaction IDs\nare derived with a private per-install HMAC key. Pseudonymous IDs are not\nencryption.
  • \n
  • Aggregate commands omit bodies and private labels. Study and evaluation\npackets are bounded, explicit body-bearing exports.
  • \n
  • Message text never goes to a Message Like Me server. There is no service,\naccount, auth flow, analytics client, or network-backed model call.
  • \n
  • Opening a study packet makes its bounded excerpts visible to the agent\nenvironment already running the skill. Use an agent environment whose data\nhandling you accept; the CLI cannot make a hosted agent local.
  • \n
  • Public fixtures are synthetic. Private corpora, profiles, packets, and drafts\ndo not belong in Git, issues, logs, packages, or examples.
  • \n
  • A draft is never sent.
  • \n
\n

Read SECURITY.md before integrating the library into another\ntool or handling a private packet outside the CLI. The\nmethodology defines every unit and evidence boundary;\nthe research review documents papers, neighboring OSS, and\nthe claims this project does not make.

\n

TypeScript library

\n

The package exports the versioned corpus, metrics, study-packet, and profile\ntypes plus deterministic canonical JSON and SHA-256 helpers:

\n
import type { ContactMetrics, StyleProfileV2 } from "@hraness/message-like-me"\nimport { canonicalJson, sha256 } from "@hraness/message-like-me"\n
\n

The library does not start the CLI, inspect Messages or Contacts, connect to a\nnetwork, or send a draft merely because it is imported.

\n

Development

\n
bun install --frozen-lockfile --ignore-scripts\nbun run check\n
\n

Tests use synthetic Messages and AddressBook databases plus synthetic source\nbundles and conversations. Never add a real message, handle, group title,\nattachment, contact record, private path, or derived profile to a fixture.

\n

The canonical repository is\nhraness/message-like-me.\nThe informational project page is\nmessagelikeme.com. The CLI does not connect to\nthe site, and the site never receives message or contact data.

\n

License

\n

MIT.

\n"; diff --git a/site/public/og.png b/site/public/og.png index 40f09972b158a304edbcffd2fb44b257c0d979c6..207d13e17c91cf137b434be75e7b629ac0a37866 100644 GIT binary patch literal 935785 zcmV)DK*7I>P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR95u%H6~1ONa40RR93b^rhX0Ptvh0000$07*naRCobQ-OG+_-L~EJwQ^nd zK2W1#Kf@7z350kZWCTc9!cT$_qTo8B#(N-9!3B^@lnvhB%FM<8*ZPd_?8wZBImhUi z)?4r6GG|0)etrM<|JT>AZ(n}*_2tXgufKnPe);nIYsbHT*Ix72H)r+T&+pGqlKs5B zeZ8LFo&5g%{XyV&HoktLlc`Lw`}^Z#W@*e$o!Ne)*FoMtKQm7pfO(&{YsqY4b(8p# zq?6oFczWwtJN1Fv^MR>@FV2Q$5Lgq)TQPS0H3%P>$>!IuKi%h%jxypvkkdV34TxT^i*eNoRl zor0p7gRCtSip%8Vb)LV?)QElMEqPF~`B^)MU`?sRBGcm0bAlLgQsO^9rsDMNTY}#f zes+Jq#mu*=2qcj%iNC+#X91jKXZ4Hp%x8H`pAd!R?G36`phPG9^_%igkO{_T{Ih+K zs2|AB&ohL>T% z?u=khWBpaz%ZkZKvktZ&b{=_!q>O&>i_rq@Ar2;rYk|^YwjX{(HYnSVY;Ah z`eGk{46zu-)%Ir=I??y`qeeY(uo{S#&!}h|lUY}+8@4+etSmD+fQ^Yv7&8Jz{dQimUOJ(Ci(h0L$LtkVxq~;0j0I?l%9wA9= zlG!^Bg(~S25bxPYmbHFToZSi3Tq~O$ths}>u*uZLFO+?x+#PTYB4LOBaYF2;q3QvvMrL*P%BSqL?{pi6rA z1!78|-hs5JnI~33)IhK1j?<>g`eJzkpDCmT?^BWo{*0bweovrQ{XEQL1-5d6nM8JL z;nebH6k}UV%iR@4=!Kk3N{^;IOnUQEUCT@Yrif<%F4P%o(!O!nNK3#HHVyOhm*!He z%v#oOC-5-AszLoVo6RRnt~RX`5pli)uySMuec}~`sD;j*fa~aG*agEeUCl^d^@flF zW=Gf5hw5aB>qB0Ll_Hl}EyRvavZGd^ssbRbvDjg?D7k6ZHs2c>9A$#tdDBRuwbd&( zVS4LyDOm(u-{}Mvcx@KtSz2^0yS;1bnQp_e|wU);J%7kV>b&Ki@vTeEa{g3~f|N6iExBvFv{hNRDKYjiE z?=9$GKE8deP|ej72Y0+Pv3OS(KwqxczR449m%BmX83X4#g^n_%K6-&u*TQ*DBX;fe z%)%lgcTmnaS#rKoXO-zkv$Lzft4tjPhKk!h}dE*A0`e=H7Lw!|BZO>)|??h|AEzBhS|K3^I<7fJFP!HDH4LoVgNCL$4$>Mdgh{ zPkl3?*St)j4L6Q<{5TzCl9dke4igxRm*urEnvp8|8(x?lQB!fMfw3{e@4lDO2i^oo zKCqjj2O<2T(IEkMe*IDoBWQyw!+z_Kp68`KC@h& z?oe$7DFA6A-EuW~_*0;>2dxCYW%FS>BOL{5W4@`?j5Pk?q1cMGt#4ONGmoJ_#?{<~ z^CX4!J9b6a!4~GoKU4{4wImEi&;bQ3^$tM*OOwFYXC4{7(m@pA(309!JT3!Xk8+~= z-3!id;Fh8qoLSiUq00W-_tAs2^-n*AqDZ@o@OkQbFg-~&efKXBOg&U5y|2sxYL6~cs!Hp#P+f7ji885q2#q+L?xoxHXD0x`A}DiDAsl7fw(l zB&@1BMb6jGha)KQ)$M@jpO3WnWWB3&kfqXVWWGu?T(zD*(8oh&*S5FGuW?>_rHmB0hlz% zJ>Yl*gJ@{Zwifb0>o;!A6CM3bIDSqWdj>&b1rNg;kem&`` zbI?FX!Y)FvYVX*e+I90%~+HOFG%`jh|o#DEQjYGW@DQD`msXyfGLe$&};eC67*Hbvw)q-gA z5E->+(W^q`;shP)q;oC681J&bnsM8Gh1$y_Gi**h4vC(^bS@#Y8I3c`WZmi$W-->@ zlVqi)JztuycjAdGLp7#V-)vHSxSH^z{v7u*7rox$JPwod4*pJH1)xv{l92D7Lii`) z=Uoq*_k1pJQVZk@{wXJ7YGa~1?zF@S&?698!^pJ4Gb%1R?bmg$Me2aHp6vx@;c(1* z8UuFjcFbw@Ml``*7a0M^*`oOOk15Q>6=4E{2MOVQXJmsfBqN9Y3UxOcZavMi6wh6% zJ3e1P7)&q-XN$Jer^Ug{Vdjg8FIQa}7PN}5lS+fq9iv<}hMKO}QaRh2Cz)=Oxrq52 zb@SVkt3E081sSo=x9<(^Yb578+CD$cP+@d~=d9E|0nTt(yG`)+1X{-FZq+wnmf#X) zqht=1GM6sdonjgCcDmPy@#lxve0uH3pi*D|CSQYi;e2iVmRS9M7*SmKQ*Z{1$;gw7$Jw7VMS|0B3PBYv z>NQTffnsY?fNQ>M=C(DQor!`t7$(ODSaGQEI*KvFdq?rNTMY4$HIu-ylYl|@W@9kY z6@P>+I(9p)?n_+soeJ&WZ$EwlaAL(c&qy`07HP^$J*keB?)YcC%o^8dw&$pO?*kJP zKn+MVnblAnO&7dJAPKyP&TV^b1+r_@`9(5s`xzU=eXV)cGr|5EZ?f8iVAPA9E)fB` z-%RmNx+|e)9l|rJqA#V|nO^cjq&St6(x##tZ(UTE&>zh84P#$On-mj|aYuK^aF3KJ zq1Qkg_Rd`*z9b{(YAiw|x9iS=dGGGjAv-O*ALpTzXq9k%|6>|Gea5vq{48A)6sk9zrOFAVDGwV{-HGhF;#5 zt$ zW=6pV$#|)dEZO6Dh_Nef`?DJ_@_96j;lW*jiOR>6#3)iyqa>2!fscM7q?_s1a;u}o zT&5PU7mBGhhfq;MHnMI?}mJ(2N>giN%I#xIDHV9QDtZ!8{?$1UleHE>hV8(FKy??AJzHs7286T0{LzP2nt=5cx4K zPJHE3i}>nugP6Ob!G@z=1Zli80`fsfm3KumD;=_#b$dTGz(>z4cT_ZIj~2<9nzDgK zwdWJAdyiSKBL`bFkW2Y@!jS#Y{#~IZvI? z-N{~dQb3HwonZf|UHQ$dMkZGbFa7iXARLFu&mYO#z6^$fTnQHxPf4M$gNI7CCwacp zeqyWfuraJgi(Nn*Oeh1!<+@ov#Tw=OB0X^m^D*O``RSx$?vqO0;@Pa%J69n!5O^j7 zba2G%)&d^H|()uQkSU|GCdM&Q3fUTDr^*SwCB1c{pUEB`tr*3Dz10WJR}; z23GF95wo64$;N!*dNYUK@sbcZKtnSiBoxr8uWuA=TRaQBZ_}t|r5T zO7YB|bgoi7lPi&r*Zi(Z3gq{jbz~(TWK)4g_(5~B*t`H??C%mH?2HN%S}fB|3DhOB z8)l_AnFoFGH>3|t0=s}r*{WfVW}I#~El08ZECR&ODE~OjY7O-U9ScMQbPIA4E?sKe zVlqUE#O-0VKj2-|4Vge{$ZY2Fhv$dPLvq1z5N3{KDH^{UJ$2U5+GB?bA>9PCdDE^& zsnEXE)>8mOIGc>N%DO0GeFh6C;T&}vAXvrDc&+Un7yrC!&R9$wsOj1ze0lHMvO6bj z5g4y`rpHf4BdmOdg2JuN4FxjSNFa4-Q6u{(HICcQ4n_uVNn0^YtPnRY1V^>k<+uA7 zhcec=N}A4Xk@m1 zdG3|SMX42TVv&}5hY;n#7|!C`clChGz!@$}L83R;X(FwD9CjzCIFrdUJG2%Z(>H?i zQuB?A%;nCA{Bw($X`quyuBT#6Q7aSmbzIc2mIF;oN_H~C^!ZEmsj+cPxt#7+4y>J% zq@HhY5=QpOyyc}Z4f$!Y!)QwPJYC1CgsC8`*kYqsygjy~<_-lQ2BwPlW4rY{3Lmgl za*$@t$k{c3Xd^zZlG3$q?&YEzb`y+hOQQ%MLq{vkWhxEOPP! zkYgY9qyjk31wxog;aVuCRSgBUdRA)&LZZr3NiMnsyK4CT{l{Nws9CO|S#NUeH1c)_ za5i<-G%`08KUX!ikuFR+fpWSr!c@Z$Q;SF-tn!SVeSo4CO_mb9B#B-2@0bX-GcD{r zAvVAz`nfpC%LZ%2j2Z+brZ8JS?Wnqs&k+1j;AaWRAMpt3ujoH40(`#YZFJ+`hNHNt`bag8> zFHQ~H8qC0rn67*B8>XQ`tkGdh)+FDD;cFgIL{dv9k9V6%b=`<#nzIL!_*!YU-!OvB zz@R`=?XA5+c9AThoJ%4YBQ#TMRC&Wa1SsjN*;a)VgB&vtlnU#c{*?m@Q%C9+6fX-W zp^(f|1fb9(Ib&5lO)TjZOb`OM;MEdmD=@CWGZk8W=+DYE2TvyH@z#3UL1KfwO5(JK zyPYv9YuDg4WrE{$QPkU+eIC2I_nm0$o|znQH%S$<@WI_sAjX~!2ck=uFVBoKlvo+* zjD5DAM!|Kg2^k&2bI(cgHb^3!?qiXm>rKMRp4Kw=nOT3&z7NDOp2;L@^iY;g0`9Vc zIpA?l?|HlqdNm(7O)9*=ZY=O3U;I`5jX_(fN9y6oymrOHQC6~3RV12cgPQgp*SJGy z2)?>@8f$;HT&N5Z+v^S`!n`jKo#)d)JA=G+{8*p;S0b6QPEcDyr!{&s^~g5eY;^n( zh2`|lT_?mA)2`{lGNfm!T@_TwJYYK%Xdj(d6m*xH&%ImH;+DmY>W(aay-b<=Y}g`q z2R#e*_5;dJHkI(hV>T>5=Qh_ONU&*hAznxnXf(hN+u%xek%bWGWKD6AJ$IE_j_+1~tq z8Gx)cY$4GBoMA@JA|cX)I&Lm3 zOxxe#9!Gh|*RqV~85GT|5gy7>IIMBfBtLI=RW=Wi7o8$%b+8FK6Lhyh6qmvlahxo0 zN1_RK5Yvcl;(L2j^j)c@g&AAnDNTRqWUr>i?YT>K!HTt(-X+R5;}(*=B%Y35P(!o} zYlZ6W`{Vvri16<^)6HCKjwu6~LSVH1kKgYdHBuH%2P=<}L4Zn2i)UPa^z`s8|$C z)GcHtzgCod9Vgo0>SYD#?xuUs+fk%4aIR8-#_f#ENh;g)_j`6fSx@9Dw02^m$`N?s zHP(DE-Q%gTf#@Q{9-_K4ruODxKC9=?_g^ih?18c~c7M9yrFEA~&!#+gL4jm2 zJG(pgDaJ$EUN9h#FQ0#i)BXPN2LLE6UWt>2gI7MqdnI(&3J(50-UQZ8h&01Vpa^9w9rqrfc!f73;OogGB#*I%Jcg#aD40AvQw1Z$P3C1SV zv(EH&1Ibj6T?3~z7aB4kP6xGlbjnRf)}{$Wn>U3T92G-g+*P?ps$dCOq%}KOI8#ER z@5~mmdFys#iM1BU6Zu4afPzr~YMm)9pscPAX1Qz#;BYgWiDA~4+-9q`ILGk~ zFG!N9k=08lhOHP27t6{c-Jvk$sCPs7}@8&)|Q^Sd+lZrT{etC!oj;-AB4ELfb=pJB0Jk zAvEK=*Rmn*aC5rAwbZbIFUvWZp9!2t4Ztvf#e8RETe2D$!i+VE@nD?Vo>_~SFYjx= zHwF@WrZVtU2%C!wmX|4qv{@~ry!S+O?P2IOG{$y^XW_xn%Rt!atI6R4L+uDoWf2gxFl6qfzb61T9RiD}yIt%MeN*bOEi?;*{pG zxu$vz)KmPEg}n2|If2ZpGejuCn$O)hO?Asauhc+4@4-%Yr^VSEE8lnOoGjq%TXT3@ zOK~r}O>liGQYLU>I!y?iid8WdPNM3=#3O3E`HTs?jVyC%!*EcuOM?B)S?iU9!0b*E zD?SMsZDC{Fj9kHW{nC3K$TTDHm`{_n1NQn!hp0L;F8dtp)jnfPnI>{HU&*vq)x^kp z7k-{u@XU%2;^|GU=YPa&crc&*QN0G#Q%)hm>XM_+$)*{0XFV)RO;ga1CkECf`rgOt_=W zBnOT}e(}~^HT1)k^V!E9I7%sl4tj`=4m@eD=oc0=MJJ3O3WvJZx zYV%jJ?B@GbtJ=SJsp$%eefoEOI1|APAPcZu?e~i!?P+?CBC2ULDk{{YgL`Y#t(G|Ej%^9X7|ytK zq)0*pQhJ_}4AV}cFSUm+m7VLc3SRE|C~{XFcbuc}P*x<1WuYNPKy?jhhbqm-AfsiQ z)4PnRc)$I?XA{x;FSB}pKR>;vMERh*Qk|Z{Q>ZRP`|}AQRk-5hU^9;Dy@waQLUt{0I*a5Gk5S#gJ=zJ|Hoje=IGfBgF16_m;F z0fPo?@a<7Mw^cyby^3n|hHk@l!`aUJsUoWJoC^22Rf0X0EboYJ5LM&Wtg#&uebJy_kHmCNe9SnWS0hb-}#qOXcI^Z-3B;ZL55)hsJc_ zq6-0Bsdf}TulBlRNpLkN8cFuxNM+;cN7c^ip4QVHY?NtQ3j$3^? zj(|AvQEeR0f}Pv^k?gB@5U_FTZgDnPv^nM*P7$V3rYIn_qf`LZTcnBLsw_zZl#*OP ztnaJy8A0#hPXmAu4|;4@USRl|_Y_=B0BopZh4Y4Ou6dF>HVq z#TCs0UX$CR-8vf9BF|#Yaj;t2?(MZ2YHz;({MrqIy); zgayFoc$ejRo2GN7m117W3?1NUna3*wEbL;JQ!Z?lS8Lb`^#bh<+I>hY3z*xGXy$3L z0d+DdOC%$*L$%rXP9%C$WR6Yz2tkRPkVU=E5W1C!h6E!1W{t#`gdiqRxh6c)$5otKXmg!U19Uxd=E%f{~iS zKVDewr*c?o9>&T|Z)bf}kj*$&l#gG((nQzvJIL0MZ!mNOM^9i|invw*%a_!UzEKZG z8O3eR8~sqpYEL_3t$=JGfYo^)KGeV{|~>C`1a#3A{oPrRY6BZ$Saplil`(uQN?V=Hgx^+TV`Dxj2wlD)@Q6K z(h#^d%9lCLJBr0>&qIBV9wx&ox6YdPhIJ->EWFyC7ptBrmkQ*bJ|*1lktqjthgXAK!GsfK4o zGXf+0Co59B@sc;EFAI>NUo7ikaFq#JJCe85MTF-bDj5wuAz&NYaRYoZ*)1i?N(E}7 z9ioBjxB=2<)MN%qt5%!wtv+)hT~_rFV&43d#Twkb>#@zMEz40CGl{<5$$>CG{P&D$ z^KkbeezkvU3isLgOqDv<@Eg~rnv8(B7#9`&6cxy*AQSh^1N=6bfUe>icd=kpsjy>6 z@VhFzjdfNKk_9S_Q{zl(GQLM*tfs#*;Tq_pN|miyox>ypxD}{4veTQ@fv=w=woIOT zjEHoIipL>BpV&HG{S*$}^J@qz%pR#Bu+saZfp}KB&`mJHMVVR;pYUYbVQ!QKTds}G z@*-AmRjVK(yP^tzVIvo?J6rf4xQ4h2ApvrWM}ot=O_HtUq81W00|ST(a2PI`y}y|BpdQIcXX41MCPIo*Q+!n3qQ7_$#M8OuMB<_h)nc*%uH zdhX^bIf4zc-IF?-S*2|x3=l_+Jv~Pj=AUffb7%` zm1(HAB;2Ko=#sK7TI_ADS}$v|1sO3x>g!A<=ePEYzUlni&ylMf2BT*|W^L=1ZOyFH zKF7VT`VGE@t==9_`l}h6r;s3fB<`0ePRi2Q;4*Z(=(1e8ElvA*+1}+j88=H2ar;-} zhtVFMFt%jSFOuIrcvaiBP;HKrHN{M(_Dv;jBG%lt7;4_cmxaA(bRKCaVBaIXEQR=Mpc}9^RRZcP)2&{G9!Al-DKIDfiCdO z8tm&SIX~K@-2jZHC2J(^2|bdWohs{(bT2xK+vRjcLfw;Q9c#<*w zNk+cA^v!oiTa5cXKxxXh)Od%A9QFiwb6msQLed&gyPWniKQf`sLpj%jH#H*bLaY0% z-PA)*iv_9fT;vWFH=kxu*@Sa!c+&%_h~mnPlB0JZjUb;2-Jfo9%xb%?#klpraM*p5 zewA=B?A^#D3PP|e&c!Bx+Cej*TH)>gqG;Qu;t`1ylzEkL!Z*P#zCztA(~=U}pjtRn z%AW97PdzQ_qXB_{D4%As6vxo2DI5}(B|Q#8O*aZ_R~Eqn97^Ja5-Ck;UTjqUvVI|tlWM-9dsLVRdEURAYHfYvTAIU0X zR-41J6H1j|Hrz22J|Ak{z*C1i_u#(B z^%9+Izq@F5{r2-O+Ppnzhu7sYZD# z&!Rgo3=V154|ZO;+)kmt<@g$pbj7gXJgeqqHaHi*cT}E3#)f-<)74{nsxT_}ZQw3P zOO9L&MKswe_0()x(^w9C-ujiW3)O6ZNxcJKE{(1lXQj4HTP^E8%UYXeK)Se%Ri9jM zx?W>=I)VfxW};{*BM*CxtUhwdtO*1&_3s_~Oe=`Z?b%lWE!>wQfwMEORJ~~oq~Ll^ zsyW1{Hhn4DghtEQ8jCAC&8?dpu>9006Vz5o3iPwAL(BWfpF#N`&W@Fz<47wmk)#13dQvK>Lk8 z3gatj{$C^ZyrHMoUNC8H;hOiX4@b{*#F$-G8Y~mCK}3e3tR$Qq6*-YqC7sUD| zs=@d5bK9>|{QS#b|5}Z>u_uJpY-{`DL?{b>%c@>3AFElmoE%=#Mtt-?*kTMhJ9`3?Dbwc`vIHsAHFOdtcYi)W z$0s1T>~JeA6?M(BLQPsQMDDoa2%9;3*MuSwT$J~a%Ft%}y8c231OLsdcFtX<2> zVVN97d!isf)OX7?r}I2bvEZkZEk6e{$Fc2+AH9oZiVQtH zy0VV>dn{Wb(%c*(fb}Gla6^?eY5=Rns!^8*H=0)!X4HDr!1(R03<8_#2~+QC@Qk;& zoz*j1V{@@RFe%kTihC7cTCcgN&0H-TVXeE_)27q)(gt$Z2HhRW;q*y>k^w7s-^OPK zpWhV!`rVwd5ax|5V5lI$UW)a~kyF!akIM^C$?;0TJm3=x8WI`8RIM2~t$+FYw;fg@jR$huEmfU_`Uc)@K z0^eT=>!g_-n_~Tb#GbX^YV74v5 zK%C!%;aRyCi3uA&>b35C`ZFh0gNd+471gui`0>{kHwiIw7RURphexB&U?iDs z!AQ4Sc$|Fs+OKwSFI<0yt~Fj1&U#D|S>bdkSpSctm&%o20tTIoOUED&vHHke?IY{O z?o5Xy!6kv0+B#vSz4+>3MAlf?#{h(hUBM*lZR)mJUD~D9o}*v)@T(I*)KaNg4`#Ur z82Tn^ujal!0td}I1KvsYe*4jD1h|0Ans=mV)pEeTe*fur6qma#X)TB>imIROpX?ps z>{E_CU(Mt_Dn5=_24#V!m~@SQXSPA!2{UjihFY3#+S^4#EuHcv$9>i`0}CrVP|k@Z z)u>3|ZvBd*wy?P*um_^!^E?|w>dtMW?8l|Svy^cZsASyIOIca~u>BvFaDCe^iDmU+ zyW#@~;dt9&W`NPP(%PTOO!a#Q`WQxqsW|V3f`y_-&awdOeAoB=`%hf)kc1I5!>-@l z6a4hnWmr2w9=Ak;j2_r(Yfns?3BSnO+p4bo`5E1%n&S90mO7}qKK{_(1Tri=&teuX zA(D$L6LsFpe)R)>Vflc!w~SoXkm;nic02JmsJ-mDX{rwP!XowBb+ORV=HxoZZ;c6* z-4I7!M?P}jKaj}pDlFHoL;4DlF>5|i?jwVi<6;O^40h1<;xG5(?C|<6R#j)qLL?)gRC8sLf$L$^=dWp+ZOOb89M%N^wLsUnP&%npxG7EOH{4Ax?0 zP{fh25wYdI7C8{;Wi6NnzR?@$e)CqSC9b!g4XHlioG)-Y3rhf`SX-q0`TPXb)N^?m zZ2ehqpV8gG0k;4Em0KlG&n<#HP6e4aK=z#C60yX(`t=p~)R!Zqm?ScoC$>OKAO%DD zj-Ya=A$pdtL|s;0f#mD%l#$;+3)1$oLg*d`hig^_Eto9ea z`%lqtHktF%QTTKh6+;FWuhfVQ)o=wVdx-ET*lo-T%&gH1a8&czcZH|c%qdDSaJsnG zHVZWvrwD+Ou6BVYQG=!*Z=ZkdljCewNE^7wIl)H!SRreMPbnXadh_;#(k$t#4iN>+ zTNgZVmk`+VRrrLGWXYLiBIO<;;XAsZDOj+aj4!s_z zYREzD8tJ0?=sX$tlj?Q$MUQ_*e(Y>AHMmAw%{L_MTF*zR^b5VtM0xB!Rb(# zDI1=R(gE&?B6<8YD=J+d6rb)({h}jCoTdADULG+oVllw3MDvx%(N>STVwW>kGNZ3A zg0VJ%SwnWF7JxCESf@*I9mrII%z!QO#a^GPkrg@y@EmPK3_gEt)RK_;f%9bEc@=|F zm4vYKln*$*{p)}AFCBod{y=jU=D`33CejdixzSy}V2n+p6g$&54egQa)My3e>~&OZ zj;YIA25KZg&xS=G)t;k9C6z*bsbsV)=5FdeCv#plU@)MqXRAaXO}%^Sf)F_y9<#=+ zPkT|ZuzB;x9KSF`!?0(AsH0Q#G62C%H#n)c4z=Tsf#~m6@@AR>elHSF?ak zo0*|8Z$dgF$7g5wQ7EZWs9_9=s3rWZ^)CgO>X3*^hxw#YdeJ^cFv_jSlpP}5vNv?L z(kw1lDa+R_!}d2Qns3&eLYNQJ3S)+!qU^YN);?H&jk!ragTn*(Q%g5wxG#M(7(WRw zjuO?<#}W1H2xV^g#8G^8ww8^tOrIo$K&&5cBMm*i1oW+L1HDPv1e;u6-L zz&!%=CYxhLN-`Ham||-3dDd7^U_;`#I$N1FOx!i^!mDX4*&eo)E|g}xMvB{tRRcSH ziXm@61j9_#Yv<`UG8_xJLflvozz8|^3ZgH_{M6GnpqDU3O;Bda%IS83@z~+YZ*Lq; z<035a#(Q4Q(D4?a+L_C=uV>upol`qiMPoCPF!>WJaKN!PL3YBH?`E7cQ^&l)@5s!#hXc-@5QFaed%9-Lh`amwXyfg=M(|b~fA>JM4UESedhwLHX~ZL*ojR#N6u>5@!@M$MBrcJ7C>9rvQxsx$=*M5nm`nv9qE zt7kPG8x^ATnK}Cq^0J>$jGEZxc^?CtCYuVFsdIED*D211#$!1Ee0iK|$hQrbr?$x7 zfB0RsF46S|K#YUU^a3{e;v)>BYNqBo565k_?S27Q0QcNakJ`i({~azDY4ojB^pZBA zCPVb>C$V(Vwt$>ub!SwI?r{V-R6C8QD0FzxVrmNj!V&Oc& z)C^esJ3RCv#An&4Btn3e1>sm+hB+|0c1a*rlxIJj`v`7r@c$F69lGWja}R=P zjUZ*8ZW@0+ggCA$RiO=by#u)wsK8S)*dZ}8M+ow-YXPh_rT(Bv6VF+yDHZ{j-1ZkN^13|Ji@yF0$0s1)`OguN{imI{`6MV@*$h zPcaJiz>s&;!{KUSCk9zPcOyVpnQD0*Q@c}Ezfv)}8Sr^!D^W+-yVNg)nGJx~*SC`% zJHfeh@4;ZoJIQaalUvlgtD96UdtzkX=T1`wDg&OB5^JV< z-Uqi9GF4w|wCHpa^O4c>Ieu$Retv2;b7l8#)-JFYfJ0%UX&OpCJR@OJ>*b*s&c9lh z$-3t_+lul&!jw?cSOW3~oCm>XgqvGyG>NcLqjtu67@~IgW2;$U*<1SiD+c?>P2E6v z7?d!W*!#_m%Gl=5EI8EXYHkA)jaR;E0&oU;F>B;)pRZVK8Zy~Hv(&xtn`zSMe4pSi z7UNJ9#pgLk8;0Qjd@U78wQEPk9N)bharI7I`UZTFB-EY$EZptuY~&*^ecxax zQoE~==Yo32f0rh_fO#+3dM@fx!Bn<#CGIHhEik`Gljq!6mnx2d(l~#tiV@{U+7sp`RyT6i-H4%9n?+U&Xg=>E%_8wV=zd1i^ zTW$+~VD@R6%TIokVKkZUb|CglaTi8ovv}K8W;wg#FdF3sGx?NY0EX)U)-fV#tUo=y z`RcPV#1<|RFVr%ok4-kHk{>JChdK?3tGn;s2I+5n*FeodSfA?36>`H?IPMSmO>>Vr z7FGrV6#k5E!ztpnjWr#aYwY zuD2)yS3?p#st~hRycTkFE^8acp~;at>{F!PlQFb8 zS9hZYW0(-9r_K`YdoHOa-J_;ce2@=KjT1v@Uy1PWSgHk?>$^(HYH90zI(de{C?mf2 zoE309tfrc&hJaXVY!<;&2Q2hHuHjkhO1eVGi9f&bZ0?KH zS4gv)3gfLu3VZ1LlP5!%1+jHOt}Jr~Vg=SK2pfPw1HX-mgKUgQ8^;89d@Ha71ge{&tvO|VJeKV0qOOK=qRx8&~k*%WKq z`Q{Eq)xD`<$3l?^)0TOxMw~CAP%+#SaK094^LZPikM1s!n}OhGkjd(2cgnoQNj-Pl zmaLb7zGER73h_0O6psQp6Y1{?n0Dj{!+Un1S|8r(rm8$xGBHX8KTE2hG`q0baXXNk z#129A;5OdS(tQK>H1J;meC$517?FX#q0$;-s zU$?SIk^s@n#f-K|F5S){uaiU%-GtKWWQDz5HxBhzU0JSchC@R=X>MxV)$-WL;A~8Z zUGuEuFK9W8c4;8MrjqE8z~N>tm7N%3lzC@sayj66@(=uu>tY+_>6%p(l%{d!4FHoz zdKD>wnHBad(|6zuku?9b zsPwK5C~c(Ou`o$MQgf#+K?$_#o8y8|m&CBvcM3By(!(|%5}(GeS!;rj&79VmDbpmT zg5BwTuyYxdX(9}!kbrr`MNt?=J+4?Y{^yCKQ^`~c%?#|W4kT{)`^>6(Hl%qygfO|P z==Y|EK^*99l#UBQF{G}@7J8`5XS;E`#Vkbyb1t_|_-va*tg^Rzn7i!)>&=7=I&$1k ztR$ht7gWcRqSM1W?8TIdkt5LI$EI|5IC7uY%*LMmE7u%xsG$02K@Vd424)r ziQd)zGXm@GvP0oLM%d=a+#pXKJ)@}OR-`PiK5WkPV(pmm zowM6{k5qEKah92`K&r^s8$r^)-pw`DM<>jru&dt@g|+N1-Z{xjMmQsRSGTt~eQ`x1wb!nSSt1%>)BXW_(@Va6_g7E3HGI;;XR>9M~)s zS1-nXHYgNAkB!+<`d2!l}zSTDT-vX5!9I`npeNlBJNOe+E_N3f7_pSL>Y+Vim~>+GtnV9HF+Ctd34FK z3~xSR&f|(eOk@1o?3NP7OW69ZzP&}!Z@3ThZ31Gm6C z#eiE9*x3UK9Yj=tEwmi8FYC{2_O2ZsW{TxIfzxt0tqYjz3rUoL@ zb1yg?Md+oPDEK|%c{apJ(jqbp6ET?xzfOugvmkdxv14_)@W;g?;m;27ID(W)BJ(;M z5cp1QHzIDacA8;YP;ER(=+MX&vGK6U%~0yJcpv|4;(XuoB0yT00k_A-njqR{RX{be zp*;lzQ~-YaR76+d1I%QTf2aiQVE{Nl$G;fd18EEJ7!9bdo@XMETTHXCL~NAm%ICm1 z&KJiW76Vs{9br0`sKMX4iPV8`Q}a1hP-kU!!^nn)1M9xbR%Z%R^Gp1zhzW7;Ks_yl z%sg4sknH~bW@-hs%YLRjB3KR7$i9oN{AT8g^GF^aBY!HY{RZ_JIpPlI84V<3i*zZP zU>0Ppr_r$q({W;9eiB;rof*p|r=n7gGa4u+7vP?=xq(j}=4O@lPw7l*Zf1h;%H8l6 zK%xTZ2-{pMsa}ybgc?`FfOg^$z}9-NxEfhe2f^R@d)n1GvmESho#>`=$d8*`e*XH~ z@B2OZl@?2kpRy2gys$f9Vd)O2!>30SCXJ~w+|T;+JnCwqo7~{jv}{bW!_Jd$&2?*d z^R$u}+Cp=X#fzA$R{*(~&3ncF(=zuv^n<35zdUOmI(vb1I}z%iz!^&lU!%D+7JMd;GZLI zdRlJ}1I?r}{3j}rr3Q#1xd)VO?lIE$?#fxP0SF1*nabGLZ~y(j`j=io1wmgZ1PY62 z0u8{O@A6~OJOgZ)DgWu);Vk3}W2s~vpHMAGTg*!k4?)yt63mU^IvyM@I8U#+V1{9A zLIErN7J}~0?;7SjtA@;0@i_%0S+>lHLpG;a%b!OXH{IBIAhPV;WQ~nQ#za-n8DiKl z!922rdY}*B8=^IgeD|p0!AUKPb9Gy=(}=kkK}r_9K;DxtzPJM+4m+JD>wGD;yV zKl)>IZc_TClm#${=ub6)PUDNU6qsd^j|+$k#t9@c&q+8pHsbcEOSMGo&#gY!Hiw+t zqa=(}cv;qJ&rP#`e@CQW?bBPgaOUl{Ut-L49<5;%LsL$TaJJW3AiyEALJmn=&y20k zvzMfxQnl+yG%MYXT_r5xI2^VcP5=(%Xi?;y0vp!*_w>BZ^InzERN^OB9q#_cm4r%% znTw>-n@r~LOfjNmzk`v0_zw8PiOoPXZ6;Z?8oYT~QgVm#jRlCaqElUNrU-lt_; zNWA6QJERr>=25z6;6gDbn#w|~eQm1-JJ-5H5;TtuS-X7f_QAgjDFN}b3{u{*Kk5Ck zjpXv%bIGk--S*B{Z_95UVRQPNvcqudJu;QS4vMtwX0efo7IOlRSy9O$uk;}^h|Y|K zq3!$K435<1L;4wXiNfY$&WsA7-A@+1_J>W0Uc+3t-+-A(L_CxP#lCcfq}VbUW>B+Z zPUFO0qSpra#(QlY@H42_HS#)CFcQ&K&waRY30?LCpGxYS2QNHqd0b=eSnCVDecPjl zds835y0}*T^*n`_2>{(>FsH3IKN%-5{$sA@CpS1czhG2UV$1j(z@;~%T@98Pzr|hW zpbl(_EcJ;PQ&Y~bjPMtg^$gz3M)WevJuovIi{4t9*bD9OvD4odH6yvvR*~Gz2=r&W zWQAqeqwcC<*@w38tk>Sr25*Tl%Wb@nY7=09faS~^u>HxRKI!aEUk>}p36e%#2zEV; zE529zR3#o8waqQB_1^K`At9}Vay0!O+4&k$4BJEeLR%sgr zFkAbhv_X-lKH}kK>+;$REHDO?@ylR#u6WM8Zugev&f8 z)Nrwwf57g+?NdLD1TtURiS=mgJLSVnV7SQ(f62NT#D1p-CdtBFNv&KIRcep3*`Y43 zvyo``Si5B1&`9^oY?^-+QrXF$TH~;By=8i1`AOx2ITr~wxR@4+*Ryfk5FuPI&ox(Riz_*aI3BmGbZjH| zxJxNZ?!vErD0?ukK8u8RJBK~yN(hQ=QoU~SfDE)f0oJ2|S4d0|o~LIl%Qf{8rJn@d zj6k3|N6y^D+ol&IaWGPi&FVaYH;37oBsF@|W>6L3p-4MmUNi*{Wyc;!F#b)vFfjWR z2=#&L!13amVY+?8I-JWInXJ}Ce5MkPylrLSZLyi&W2=`bD32zkj<>4`uo!P))i}qe znDqm0#+Dm4j!6jN($t8a)}{+d(Pju`jXonnAdZW(qgtGwD?0Z$^|6x^0(AAA@=@V5 zQz0zszOVHtI{P&8HD(;wH!!EAr3Xws=0xmPvEj6^TGXDdNhLc7kulh!+Zw0$4CT$^ zG3lU|T>#I5_Q8IgonVa_JYSjI^&1H-vu5~>XKjaQfw3`3J=h<> z!^sdwz48Icb;w|~^fH!6opl|J{s_e$Yd?^Rq{TL0?m-XjLYiG;joU5ro2*o)M~zVS zF(DvQHoa=;+i2>f__DOmYxy)0#M$Z6%Lwd5NzxH}YTn_=K;5<=^{m;TYdq(hy6*F` z{ZYpcM@>~-lDQo#ZNlBW&~v_2bq$Y^n@nd+=q#krU(PXi=D=2CGrJJ2dl~6fU{aaJ zZv9?e-nhy>xGW{y-=*7V3-0RdW*fX$5cS|?qDHSGKxYTb;bw(}m`RibZq{bMS>8tX zk3RY0Pb}nZd<1Sz%(lC@&3-K2a&lqZ#8(8RX$+tSZXjjJA9QU-C_xF?gl!UZ^%&aH zwd{imtUSIx-{f>Ediu$5=^ZXKVtql0ae*aLJ4ug(J52Q=z)$$Hz{M2coJ3Ep#M`_8E*sDouZ3mDuGZY%Lp(o9=7={QApP zfaqr)3qNlbcwyVzAsv z1gbR?-^{)cUPaG>8(KAwz=FwJlAHK5v68sN4uD@!i&)NZDR{&2-OF4|;# z_rzS4gwjNiZ|ZZsH{!iFF5?k`D;oO%EW|wu*oOk#ZE4nHspc?jd!si66n7yZBfQm` znG7c19VAwO?rQ~hdp?RqU1^PyDyLZ$Qh%_d8A^W;i`5^V_`@H1&*J_~q7?Ut8-#C{ z$>(-~q8w>}1ooonwOYN4RVgl8B>>1FUC_g5d$9|rj88sS*D`Cq)(a`Ri8vN@wSdW1r`GC z`*Si)??~<>2Wqm)sH6A;PT59)%veFfAPkXOq>R(k;XNg2X zt-Zg6HXAvpceYlms8L7Ry4gU^28hRSIg53dNCX<(ZEQkm5#t*k6|7v9waSyc{`zXT^WU9H<5npmOV3?Q-aOEEH}<-uEh-#ACbu zaCG|r{mp0A{^1{f7SS0wNO>|bLLNxRwMP`g{_HvPDUNwub{5unW7b_xT0;wqBzRT@ zG%HaQu{QfsO;H{qM0a0XS~*%^a8A9z8?S!EOuDuW8EAvaYb0hJ0MU53Fshjw#U>X zz`qf*?9xgsoxM(P-o_7J3Kg`TW-+;{^caXqE4cP$-z(Ep&Zbw<9`c#!1JBy#*GsA! zSRK`ltDtrJ&XO~5;|L!q28BIu%Rd>^-ZA1CZ?D-ASKMnk9ZS$napkFO_oBLn? zcmE|r+>1~-Knwcf5n0_!UIr~fUV1Zac0Bh;rlUF(re2mBsg2WCr~TzgHbaUdI#~C5 zD`nMwH~;J>a%XXF2*idh#iUK^S>DR*2u#Tl_&h9~OLdvS#p-}ViaU2wJex%RjsxaV1G(U7V5@rfKM<`piXZ7EhlyWODO(2;=6B{W2s*& zx<;$cI$oRzwm+=OiPQ*q*C)g}4bhcdJNPW@jRgsoJ_shn&nV6HDp^n^NUi4G|7Ngp zX3mAdw2T)NccP(#JnZjwibC_`gJUVpb)HnW*EBk?V30JBFJzMjbL&^q+jJJ8C~8a^ zJQG}Iieh^s$b$t0JYLI}7*+eI>`7I)=J7_v-e5HTLPK#$?3j0f^oY2okRll}u`t>{ z@K~DkE_PW6*#=Q14Y={*ozm?dk|O{HvHrksZ=vruxzk^2l1vMPqGY_u>S>vA>!>iF zcUSt?0>*5QjCOD?ky+w-s318(hz*h_uKy#AK=P z@ySIHl%(`+DvzQB5(g=xyz`CqMZ~2OKOk}1HB`5}Tu14eZUPBzo2w-_9ExgTO-6kL zcgK&ZQDMedZ6q4WdECV(KuSGQQWX5W&*D&xqccgOPu+lOUabetwC(SQeq&F+o*0fX zK(j4nZPVFJH%9=*3ISj{k{L})zyM_yRrjfo1=i>V^jz>$Kl{SD{Ph7&XQe|LGM0?G zUG>CviR_tLkkFU@@F3^!?1C{z5x3$@mX8gHhTV%B=fK95wp$Qo59(9rDoNH@k9ctSFvHIP~#b`@$B~hDEnm8k6Cj-IF zY+mye$0^ZF5OogbEPJWb=S{`z_$*#pRQyx{m>rTS)jm{|of;|g-bm;y;an`1`U}@a z1)FBD>EPd9VosqdFWDJsyL?^YjY<$?xO+53G}>R4b_y&%`4d?5nC_O0iw}|(vYzWk z8VdTVmpo=-O&FNd1b+owjD)90?F-m^NonU+SV9WU zDfXFg3_ujnU6-ZR>=zELS1Q?e>JKrf>a6rM^LY~*qdm4*XIeKKS$Ps&%fvMHU9^id ztM_PwC%z5d62t>);%1{~g%z&s`jyMX&Seq>UNi1QbeP>*_dxBHm))+A21-;a^TF0F z28+?mQh=M_GE>?ElZV9vVg_@S{Fc8#?VLj~d!)$K;HTG3q;G%ukN$`M@qhkb{@4HQ zKl{)Bi)Mz5d!Vi()D8_>jBFf10ic0!4~XotV&qeO>%?Ng3JYCB>foBl7YNe8xV? zrauTS2Qjwa>r?L8CLjEc>+~G1b{832$w67=uQ_{_$@YaqSi-j$xSOs)!lCcA-<;ID zbqgI9q*<91j@th@2B3%*dG7H7wm2DA0R6kRbgjhwhFJ`DOL)J7l&9pP-+Ph;=+^bN zfA;CM#zuXLT!5ev^6$%j2}u%|uvcjYv*4slcjSK%*~r z@iR`7@Q#`HF)|!@?n$p9B;4^_JEF*xIK|dQKt>8dS{8WHb*Y>x%phxK$(0<&&1`mn z@ldH|%Z7Z!0AUym7RHk^kTnr|HuOTzJT7wJUlAJ7<+}lR4CCeN=O2IktqJGrkK2(a z9#Rv!VJ>$FNjF2@C(l(syw;bsVvUD-vHnu&vx}YNUn#gxrx1{%+&h9Z4+!ixc)j!d z{V#r}E7siX6z`%zulUaA98A5!$WGCD2hYLwGpmQ3_z!2eXlv_#+Ez**l?dbp$oMGx zhF-%W13|g*YhnKJ(|eL@Bg3u@z(vQM zC~JReVq;TvneI9{U+uPIRL4S*t>NFQyt+dujmq;N_{z_Ne&%>5fb+ z((L?*A_DypB6D(3eKf{=lmWk+P-t_~Y=evnsu1v&O=0(!fy5fwN((Z6Dm2c=YEt7b z_5R?$;!4WMrbjB|I4x4tfR%8ms#CCGgohbM zMx~*@<-Mk?dgC|5N_1?G#O`njJcKTQ`Lv2O$Y&q%vsO4wF{u}hZ2&Tjj6u(CW_PJv zPhQ(aRPuSPJ#{4qlp^mdAK}w9zqKFYqS?cWb5T?2q`!1G4{!AlL?UKoJ4Jd%raao@}3v+niA$FjOGaI zV=#uMsyfaf&5EYA$yV6*yci6aZJ!p+SE}OMUM!Zkg|0-~apH{XhTv|M7oH z{>mZ5TYG)q?87mgI*BJ^lXBzvrzm4u`u5a%H!y5{|y`txrv2Ka`?vOg*Yus^3} zvZ8};XoZ9D`GUK%**%5%UK0HmldC0*#epZ1ppf%KScTpCr)2$cCTc!X5;R zCJC?}M}()b$~>jc=Q}^fR+2r}#9s38$JjFSlmM!hmQ_%lkA#=X$q;kN;*st3K1-5v z+R$Jz4;a5kA*?6?V_WfDWjyVfc}6z{x&mPUg@v4>`4Vgq>~u5l6~;#;04wA)nPWZoV#H zVP^;;=p!-BPA)bxj!Tc|!_t>VYIK}VyZ=<1;QGpJS2L7qn51KqalZxJQ-am;e)%Vl z7%cW}Ll=h07wctkq%H{B+!mg-!RZlKt2m`NJHDpgYmuyZ0a($}Ca}=k-a)}kVtqMBJDg*FVV zuV{!bRy80ySxqC41=rAJcfd-@d;gfrMeMvJR!8SZ5OV-6W0~%G`^AEBe?VrM#IMRz z>Hwl(Z1oFSu}FB<+Phi3LWPyQ7tlbjz`6!gU1>i!+iPZd#H#CXK$3pf8{+7RErnrJ ztR{m~x@up=+JHV^46hP!vZ~9nzF=hUQlKu1MoB0sk-(lI?DL;E zwISB->Px&miWIPFpbQGpe3wYMO{4cJ!_@`yj1{;t?kl(a`>r;;I|ra46U{Z(8@A14 zISd;|AsO7`GPK*J3&4Ps%5lf)`ybv1@UK*zsR?ms%GRg1=->NSZl?In&MU}1-fnk@ zghWgd@4Fv7M5?OIz7Lz_Dq9^iqD4CCH~~8-1*ocu>FbYfRBhK10|Ow?G9kW+qkBWL zv%80tKHoDmsVo|d%&Wc5I?epsQrzbA&;N#H!7Dy9N{j<+m)yUVtTe5!KBGYf#JmM*S`ZC{CS=M1soGlfeWlVw1zFPRMqF zCdBQ?MM|ZYsmQNY3HG@x5PfrL)U;;pPe70Rcou4s(~;$C@3|BQEbUAMA~%`JvG)&l zC$S7|D#-rV)sLXm$#FrqWG$nhAS5HR_x|4PWHqE*%u9&MZkBRtPJ46vGPLR_f|1I`Q0PEfp!*K+5z@&ip*s@&Pqb%h^pT zOYWjDOz>81R4s#ze`Yi6c3aIIAb>YLomT=~MHNQDL0IHnac%7bB|ZaRLi6j)ABG)G zB2{~anae+w?v8_9P`_g@79&elj4r7gs(z0E56tEialsK=H-o$%xz@N=`B}G(&ta;J0TVJ}f6HB$qL9>Mb&BT%=UcNT+dbWIb=AYNAq{Gin)FljgDC5D&L@^^H$N_{L z4r{70S4i}6iCMGxNwn$Roe}P-LX<#fgcb@z_i$P|dI>qa)`?O%7Q&u>3l z4MMUI(jBBmZgSaFx@wAoC9h|Q5cc4Y5Fio&8q+yZI2#ltdEb6~R}a2Q{l^Cc`f6Z<&Hqfo31rxy3Z zSWq^WeB4B_qZXs6TMelG2qYs}qk0KM65a=5J{Mq(I9o(Y)I%HMgRZ&eLb@Soj?AYEl&28)#ARA{vRQj^3oE!v??&8+GsT+-{K2hZKj30*fbQFg( zeA$e@84;uKX{%#0HNbpXP;^+83dVQhdD(fY3Ba*1#l*whLi}vPQ*(!Drf;;AJVOWa zYYtzl^&+t(Y|ygUtT#>%6zSWE>oM&H)r~Q1Hh7e4Mr=A2Tbx!W0&ON(#dL0}g|bI# zH5jfP*-Ur$ec=(ya{1_^FGYfS54@i)embf4veHtpI>%A}VA^)m^wjgi*UGEYdJV81 z&5G~+<71O(6L}yR6#!mN2ArHmei%6~SLF_K9f8ysp!U`ZG&0DXtT5mF;rKVw?>nw_S#NG@WC> zH(_Z&pz^M6m~6y#K_0@|>v7+t){F_-@1nG0ZU(4Xz;-jGsf|KqFtY=@5Q@y|)gAoC zCiV>faQ0Q&zWv3{a{n4Zn8npwxIzh7dHMX_2Y2JT&t8Vjh1pUT(D5Ad_*^8bsx_C# zkTtAgDTWWBQXF6MC=Ed|2V=KCDp-pdqVn!hn4%`I{NA25u)7YJXf*raS*(^*6McrZ zH22%ihpnhXSzg23ds$s?yY>JZi6gIn6@UX}H;|%3b{;fiYa`A!BzVDq{8Vxpwj5jr z1rl1JMcnhCR=+Wl8k}9mJiMtRlWZbswyW-bfFFg8h*fyLQcG@0RwVf>gF6qZKmyTe z#H|=jFk#2!^QLm<3~f=GB@RgRrqqSVzRt&yp<_I``9lkY!On9yV-Ppgeamf2=d9UwvS{3p`4A3u`> z8_ouk$;`x(!`T78it`@@eh8`#Tov3dJ0=z;grm_<);KFO#?bbyt$?L|bn)1mC#tD)KdC6l^jkP$Z`9qIwlGH^9|oi8 zaWL9$1ucKvrGmb+K{7)U|Nh<|U_?>1x}+3IF(y5zJ(*#$ffb*(@Aaa})-GD=tN$pX zH;q+}58C`Nzh^(;-W#c41L$LW*0NYcNBWpSj*1>brdF36E(}UjupY=)iS8f?dkG;71;7os=f+S? z&~G?jn3y>3NS3LkyHTZrn;S2j#YrzIReNX!es|Nim)cwAH$x{ouc1Hs4gPrUerS zGXH<3?qOrHrSDytx%XOg#*7#-BId{X*gN&DSYG!rkx|aK-h(cu5F^KgYwE0|xB$JB#!Z#^ zutmoLIn8TIk%4i&z-VQ>&c1f2o=TG)28J!2N6j&loGu0;f&R=RV!;QBYC33G-XA=1 z@SG=FF;t_G%)w-I1Z%7^R@$UT!ey-Fneqk5@Hpi)-VX2;!pm)d&IN4XpyNZ8s&Uw; z7+1l)O&62t^9K8dr7$3)?o*dANSXqWqnIgj)Rsd_PGhyj zTk+%Sai(eEf!aj82E@5ATxU?QYmyiI%a2|wO0`1+7?YL<+xSwAbO<`O^fn9(w(-v3^)2dmA=FYiEKMh=LUmZ9!@G(jo z7;EF#R$21N=nh`=py8N;)xJ4h9cG%`MWOE{yWu#^wp)8@QLL#Y$MAZptCh8|8)-n&eSQ43S56Em!!B5aK&)K^aH4bw>BAieE3 z;kv_V5s=PIkNZnQh7g|j?X+iQBe!WX-6ql}!0B$10-T#el{UeZ3D$NUW;?3* z)3%(dE-DL5>-Tj{=YABmTArk3T*BGu45ZI9amxUjkDhBvc@5K;ue@N%U5*XA>%fJR zJ6_CnS+^$c#3a&N1op47(mKA=rTutfXFvI9ASw_}Tyc>!Qb!JXX9&7^Ite};t~Jjo zs-z=``+w{Bciphij#&hXfgCexDK zQi`{oC0llk$BG5ILvnMo0U;?2Y?c20$EVM~o;w7{&|#{o6mP;o>l&TE3ODM>xaGfs zgJS&ggx(rXQEJxl(^p{})iup>{kuOGbAI%{`$`_Cf;T3!tYap^s01wUc5|Re>0|J= zOsvmElF*7R(f$(c_?icsr1R=^NkT;)m(yK0+T~trn8`6J3a5zDDpW0GPr2I`MIp%% z^w^fjQNhQ>yIks9D^gT%MNPYaxAj^=VS?6*6;^V~oXEOCAREJ*&fw#pQ@yO$q@ec= ze#u$Z)_rOqtTgsI*A2j-GJ6swZJVVsnP`@bvmKNM7ROO%(O9E%ic82F3<%jz<2rmb17JjR#x7D}O;4n_ zl+gKr%l0`r8sV}a9_BQa5;=Yv)9vnG=PR$CFzlgG+KYC{7v>Zikpdd-jc~3H(GXjF z&Pql?fZ}{C0%f`@o-^Un#uk?IW#j=?OJ6PH5qUIswk$N7oBJ9|T?xWKO7qJ~B?Z{( z|7~#Dw*lds=l#5q_8_fMrQou)bCunoBUZA8@pvyey0nX{3^rHO8$O zbm@i;XUr~I9kCY+ITivZmH^hQYNJ3fpXCHk4SoE8nhULzOsq8xeNW()s}^UfsMi6r zlMc2-B-h%jMt_L~8Iz3(C_vM;+pTkNRbq0fNgK1*N##!oS>kCv9h8sk-fll}ux**H zWs)lG%^uEWsk~nyP*%4CBG{G6%#AbkqYm|sq-rk*27XI9*6$RtYQ3kv08l<0iez~q zdSt~pV9{dRLE~`aK&3if$nkT+nH1bvy8v0Hp11Q1kSb=mgzU<`Ykbs?;cJPH=~?oS ze>BqDjdGZLntA~;Vg^H38pK?x2L4{>fll07dN-rdrs11Hi?X%bxLBw*>1%!3V!?oD z#H)>#o25&nM?zTuG;qC8t9g>7l0&X?iNSNf%ecylR8==qW>HOzNWH0~B%{uiR~huV zmu#rcN`dItzPLq>nAMPLN>X^ul2Su zFgZd9nKa&OqqDuqr*!ZDtm(0v#f~%V1`MMmm=1N0B~_qFJW(cT6xtR?kJIoXM-SLO z6aGJ`_w@Cds6uEsG=x|j60N~ON`mplPy0TZV15{duIS&uEh3u$lHRx(*E80;%RF#m zczmsIxU~7Ec(!(RN(R`5y=2R^pmY>vRmD+j`gH?e##O zHe<`s6_%Y(ZsN*kK80;gD+4y(8($3d>C>wdfWG}zjXraG|WJT{{F%#2{Yl6u2Pdrk2-pK zO3vhGFNsjmk-*u@k zu4~$qQgv#3C7V~fJm0ryhN*%r7H4ti6Ij|+k11ILsnn&JX&Y&hEi^qi&=~Cwn?P+H z>|1|qFZ$RG(=6ymZPJ6T1`Gds{yfYN=Uw64_EI|ltq1xKrA5O~wt#oZtQ_SSF-^P<%venf z0yg8W>g)^}UNd@KPcpYWd9DE&)^fa_)~Y?VQVy`(xtw97TOwnH3jvt%L>J%WU1e~d z_VcR8PgawmsmBeZ?zTvcDN4mR<6+aU+bgNoeQG|~nLaiW;mi^sigvquIKl<;DVHKE9hRVQPYgc`k^Oe&f)-hM6Bp zGIQf(=i0}AAbrDD2Zh1y{kfA@oGF@{AY4i-E*r)f;$p^=@Ns^kOJn{R8I(r zgWlIN0QFNU!w&SM&l93mA!}5t*SqIjnx6f2)i9b_`&k=S3o%5s(%{+!MYbIN)ls4e zIgp5Va4lgvPy;P_gMbL4l;TV;o@?!Cxw(v{Z82bc{axJUbOTFWvZ^D&j3rB_GNjdD zp$!TLCA@dPy-Z??v%YD4*;Yx_MiH=jd(It{k5f3IuW}<#D(7_7kChaxFoXlQgItTT zk;XbBlWLzTZI8g^WJmLAhFV8TXNZRij0DaZZQG6Jd3t$xq^nW-IKE-3k~i20Ko(mS z%D2Xck)TsI9&$2Pn~0715T9kX%I?m0w(!?TAcFy;g;USyEZwmF6qt4hQZxbRK;*r1Jn0Sw--%j z!4lG_7adqI{Y0?YN}{x4THXsWYQsSnZ9dUJ*w0_t9=}U z=8YiIY)|Da-pjNn)iOADLYM5$oZGWwh{VIL85e~jW!&njo*477B8~VGhuK)i;Ael~ zszkCPz!j!>go^<8YQ)R2=L%m_XTwSrts0A}JDd^Y|MH>j=Kopch%GKM%#w2t{6yD= zR47ta<@;Y=UR-Wjy2vu_YnuJdzG3O&!BW=Bz&rEqI;$kin8mmDH|3&`m!+d+UGp|=T0h2mmN0g#FnVdWW56tIRT5T4&k{V z9dwQEYIL`%r%&+oor0oo&0Mk1%Uf`g^+QQhkYgAn( zv=F&oFr`cNGW9*y~&3#+lN7tbj=)1HMO-DON!Vo!mfpDkssYh|y zugk`UK*TELi%AmV>ya=Q)OpVfO{#JJxGi!lp=y?eI7zPRJkz6d%g||^3XJe%s;_t5 zr7P$5m6S<}TWW4?P>Bn2#3k6W-35ks?|MWw&1h!=F(QuL{s>UNx_eDIBm`PPih;j0q00TFlOPZt&D?gRFJ$ zdx`&PQl>CR_t#5JqauVKda!4HtxLA21@g7gN=LSMt_0!|Akx-(i&gK!DylASmWmyE zTE6Cu#hK{K5jLA>3uK>a?h_w5y6G=uqHk+=*A5Jk+1^bRA#a2S=#k0BB@}rWZg?w|Ku(&V$ZlO47fo9*-BEDD*lyoOc&6eE8)odUp^~}-g%dWEb(+xqhXR#cNzV-OUJ;zcJJOPbL3w=+EePmqz77lhpsubn{~ zKf2^*zHp?zgVqOwB>OeXPK>Uu>h&3^_>{R%UTxAS?U< z?wxkJ)D7|QRHA$icqb2Z4j1&^7l<|CT_9>9Z^=xawl^5PGhvVs5nUKCH_MgRemQ@I z&!ru1U-wGQ!2=Z-CX&@vsOg}R4Nr$O0okoK;B0kFM}l>*#c*y*i;XAb;&C?97ao$C zH-T!`zW4E;lq3vCuz7h*XY}I09HhOZ2XwvK+7I>A)+%LCRy|+rldR79mX35q^HBvh z=I?xD(x2w%CO@r`=!JOf%S#9OY^VRa=Ol2TNH;n&8@3xaCtM%jt;xeFe4)5ySzN4Iv4B~81!-R!_T~&*@K)yauFi1#Kuv&4S-+EGn$W#*yK~JZ-EVH$Z_>_&^PVrid7AN;m$ggH4U_n_ z5Pyg^lR14#+hes!w$E3JOfHmeVKKhKa7ojpLucS3ut>#E2Lp=fe!T?O1M7UraI%Zn ztsaV1I|%7qbCzWfhiX?QC0!F*VGX~0O#nM8rP217t-N7NJNKJB1T@=Wj)YA@s;Zxc z-3V=Z-4$!xo+B}`EQ1O#R`lbyi>wkOn_C#oB6yAQz2+Er8)P3MvX*M=RHNOpehjBY zGFi*kig3T2qTX~4r0&+~L1_mir@MVTm^T}y;$xQv6wy)a#7US|R1JEkc~8ChMzXf5 zMX>i$4_Br@{D@xFTkVc}^KG3Ui-k}ZqZ^=h4Wit{DZGD_CzQ}dxB z+B3nH{f08@KXYzHk@Coq{F|rgJt* zwe||OAKSy~NPh2wuL`Toy%G%6!CkCd>Q~bi7Ce+qQw2sSGWYGv%+VkUy?@Ggey?R-X(49!vvAZ^`FM|Ggxkya6lsVW@FJOyc``6Gk zw8iZ?OF_+2)yJmV>kd_#wDYs)polz~As{p-kU7g8-S9SCRhe*Ir$Mi6Xg#f4))c2H zszaGRgsANpSn?%v@_O9dNrK-F%b;^z13#(cQM)ozw@TYBXDxZ((q-fXpeR0 zT~d6WQ3wn?rCjNrqaQvwkuU`0YUkUW??ZRVfE4~&Uq2Dt8%A`aj=CzzpAkJKq**&} z;2DCglMu^DO)}nCk~_sp-!_5xpnRR}z9VO1%>_8l1vxkfkffNlo<46jqI<9NH{oSk z4J^(g2WdNTBsqm^GI%W4Qv%OSj=7d4BN)-g3++-7pY&XSn6A1*a=kp)x*R6uI*0*$hzSNQ zAaT2^09yd0&>6D(x6?skCc2u;;WSb%yBcpBJf+J;Y8@?+lo~hZX8|`y)Du0qo%0OHCJsQAiGa#}VTCnsizTN1$`$<)gdn1{CQKji%$xKwlqnE!m zJ9LFdA_4@h_I8-2n^Zt%x9X0Qy;G467Wx9E3onnD5M6=3X6=Drs+c&8S4M*UB$!p0 zi$7cQc-h(tZsV;fRYFO3_?GV*|vOuU~uo_of`& zaJ=RWKNz7MLf-IW0EUn6;aNZda_l_6H$vtVp$*;AY)9!0Rlu`RZsqaVaOq^OnHCec z$if0l1#{*|dU8O+Oeb?4IPk+jm?OE%V*A@ehLf2EW+_Ly0w*`m$pRUSNfLW^+$&y; zu7)5^vOSOC6m&lm3lhnBalG-e>SFC3ihhEP0q!Y~WKpqeITWUL>Vv!Ns+MRqbyCQ1 zo|TRlsVOku5MO{+?-W4^wp2vu z!k}rYr?gf&h-$87=3uuh(_-9$tf=r{UX26I-=pXM_xK z@U)5(@%Bh&_0gUER(QKuYrbkFz59W>OG;C_l|jR01JKP1)v>yGQT=Y$xy3cqXVkX8^d)ur85}c zTZ}Z;_CM=&yx|eksJS75W>Zu~%9(vo#K-(VY6;!SWv9wR2k=X>aOb~q>Sy5Wgq^hgAE}3^VV#SzyOMlNP z5jHL_x0!DD+D(6+(x4Tej0spAkCHyafF0r@>S+diQ;DSTJj35oEM>25@9e}_TbQI$ zA&ZM_^ue37&w|Xc_z>t4=xd&pIvdItx6BkEkMmKJu@u(gKF-o%KlyMBb&e4jGHn$b zyozngeM1R{w*fhZFw3vD>eDLUGzB&h3zU9FdvWP~Tii(!C8f^NqP<~Tmfur_cjCF{ z*+Np%wq;CD%{$$b@eZ7(Ht2=wdO->wLo-;j%)vCw4)4Q(VweWknwDDCKSkb)dY+G`vk9|?DeIuKD7%g&Nu!e5ZJvY*1GVcl~={gwv(exMcppjSC~fLsH{a^ zyECP`))3*Y;@4&oOn)b_E%ZCOhjP0EGrPgFlHe-ITz#A4QN6^Tlc6#{4w)!JQngPv zmLGC54>LPPDUG_6=5>nAq!`TnwT+)>>5%nA{Wkgb5?37bv^>fKW7zAr&Umhg*QID& zB$<`$HBPXg^F&6z%PYN5Q%$^D#!gt;R6juEkDnTOGNENeF&PFiyU>YI>vY3OD}#zT zi<$Jb3A6;}&5rD6vnO2}6DoRo)^aEr-8Og{RoUU99%HF&+YtT4Cr8K_TfD_nL>T~? zaEW1Y9ih`Wl`U5q74OpxdFmFc1$tMDQnFx)Mfz^Uh9iBpMxci48%7$GaQLCF{p6(eb1n8iUs(|lpj8d1*8k!vv$rZgn!)dgnd9sUz zP^N)(E(lEp`4aFP=q~D;bjng4U&kv9cILVst3p2VeU$C;yw26>~%0J#$YRwW0 zj95@LoMwCfOr?&*F}a#id)k6e0j7I{d<&6QHcOv%izM}$-ooRL`efS=`}B%syL8-^ zG#nW}Ea^9TNR>n~xxUtd1_(|_`7>(NMP5kLS-K(xOLTd6NXr&JS#bUEh^ zLxgtNOJNf%!qz#s)C}4;K&VLo06+jqL_t*hA+Tx4R_W?pVC!7(Z~M)G0BB$R|esekniqjtBQcqVuttuGN;p{Csu(@jCN-42P!|95 zuED%zYDmzmx6h=3h;^C1Yls9Sj6R4gJtT`%b+kzhSf0-GVpq-k*T4I#$CF%IkIhU! z_}aY%CpHw`>W#td)Gr?v(x!kzUUes(7OjC@VGz?+sVm>08FQvHNO_y|+}lJ~dH*|^ zXJIhlVg(oIvpwdJU$N#5n5lO*g7^Qsx`*8N3W#uR!lBKUHQDMg6k*OWp`A?< zZ9_U}MUXL--Z+sk0Vtco!M($L?yP>iC3;!q zl`5iLALvvk?D1Da(i|{asOM>}EKwUV=0IQf@^ZbhWiDrBpXT+|RHIRTOv`MfgywOQ zlg=anwVMy*&o_ANv8-CKWJ?9}b7VERo65IRse`z9#QIXELQw#pxm9^JtW5(0!PN;; zCOW7}a#nYk{L~%}Lqw3YWuB8WC5YcVP1%pq@M(t-q9Iv+L`OPZa2VKYbyW; zru{vTgN_+WaGO-z8FD_*IzqIGylgF$EHctVAsHNL8LRUenVCdV_LYn-B}2IZ0S>6u zf}fH0h$=OVRIj{Nl}e;pByralipu@l^s>Onb{&mWY-*>V!v(@azr{UOEYza|w$bCp z6CpARXeIMD;NR4+zEcLd9MhQUlXa6p_mFJ0G+%;yC-&yMXv%t0{3MGCJu_2%)sr~F zgWGC=nQJxSdbEd?BMFqTHA>rgv*;iPaka9lmQ+%1R|a(rhwDXFwi&vNOjW3~8{pzq z)BM58O~D3LqiYng2=B2rjU&1E%I(GYyf_n#273nyjB79!1!TljKMmJKKx?q!&w4sL zq?Y$ydW$t)6pmuI#Y$CcH^>kezLFjs4do{H6JQ~uNzGQqmS<0UCQAO)XF3%Z`?Wf8 z^XBDi2^S$`EE|l0X;hU#u%GZLdWIb*DqHwf;%0tdg*1BG(MoA>%=e|jo^Dru z0D*H>rTTs)Mm_%~ph|(sX`QIcrd{1J04}rU6~_`9*ax2GJ4I4XL-}(WIwAbqzp5zz zoeAWU-q?*AYbM5TR<_RbG=*4h!f2+|%OzC$x}E0x;v0E!l6CV)2<83kp>Gr20ErU8 z`9|RUA?$WmGE0}mr1U{&br)6mTtZ{paGCFY=UYuS?j%Fl>}5~8rpXYlg|;PTXE;2o zZJxq$DbmpR`pu^e}hw*aBEgu+*QoM7mfDa~Ca-RxT_R@HsOCQ87r|fjUk4 zNcuYJZA1~P+JV$u>#H?#=W+3v&ziA7srgH7S}q2H%}LSP;nL8LAG3tvj=~G`l{0aZxw-=UEPj zmfjM6?)4reMQtTpa%sUw;|wA~={teqbxi?V1c&U(mjZ_F%4$6xbjl1(YTh8PrloFtv*g-Lt0 zPymx*A&*$qJ}=0JclmyL0mkReS~b@S9oIh2Nu&JwtW zHJN0l>vVT{8`yYD7jlvvLm8Uu0G|NUZKK8Bqq!z_YbWgbVLZ=KJ!J~hP2Rq)o9#O3 zcBbQmXSlZXaVH%!lCL4%(G#8HXGzvw)VE zk)PD#-2;{8MI`Ui_V#c-^3XZca;K&dP~dD#9sSk!J7`yRuOm6NZAe@79*Gh?*MvSv*pEx0ApqpjfnAu3a(% z$orVGL+^5ahXy*CtZFU@lR=wgEl;wt_&1M_Ub&Y3DXsvP#>UnyIWX@vCAoI^U-^Y_ zXmh2n+>F~&Xr@Gs2W$R@^iQlpGm$y|SpOMlE~`cYo99$vqSTryfgsm5%sIQ;s^cl= znWddjV!XLYu)>3cnRP2-4Rn)#&J7lVhB0JJV~tJC&m=V_tLk^1U8KTMzPSEN@uF6q@tV!-9t%X3EPGd4xyt5|b;dVOl}PkeFFzAIHzAVw zBp4OnvaS}41`ob?-u3-&o|UT~&*rq@A^ZI-jBIFClc~(DJ6}>d`|xSbZmMgUw^>2B zAGJ}@TUMY{=BdF}Ed3-I(sO#kgGtPKaaw|+XGND-&#b2Co3TO4!kL^ALdlqUYGMRq zqQW5Rx*f`#Ik3{y?vl+KKFOW%W6rpQlcNL|4(PPyLP-8}LRgW(@2C72-wOvT7O4zf z5!#*zR>S_G+PC>11 zrvruk(eb>bsC05qiyb!6`^FLgm9L$Kv0SgX<7zl;QK~b|2;smc!tz;J_gQV7W$_iM z2UA#|8xb&yLd_n7i_{6G?JcY1ee@QaaJNU?de23;(C_kH*UNiyqjjqD!xnQl+ifV< zf}?{7c}7DDNe0C4rvK(s3tX6Mz=n=%AC^%hz(T`|h>|7+3Bq@UYzc6OL}K zVUTBqPxkjmgD1rNvv>eQP#qq@Xwu>)o>h(m6@8srYaH7A&kEb(caKcv_p@Ht$hxBA zK*=*UGh_k8ehrz=>!(++#(Pee+Ew=Yc18qjnUPz@nVM*gxn6nRK!6e+=C2cbyPd@m z5<6!R&RRD>pYb(FFh3=Mt8|KJ>op<*IO_~Vm2n$vKFF=mMthFa@tQrn*pK zXFFJ{1p*aE_&v17&S-j2IqTybx5Q!{FMpd!`>-X>6K5-}8~v+E8&W**)(z4kR`Xu- za6K|4>GFVyPFL5heAveT(CVpe2be)A<6XVR*r4o&n{TWkQ;G*CC4A2MF5GA-5j-f> zt7?t+0CsBIZ%xL?qGQlr7^QhlGoLlSEzv{1HN*pIX4A*h8i@aRvy)bQoFA8jY-JR? z%P4qAFkvQT-6+8(M%TXd@KMhMJB)_9WEG1Yghr&kwoG;RVAdwA^=tenK7Dj%%(!F- zi`@Rz=kA)gajI&rMTkZO*g6g?IZP=jtIYRvo`(bA3-&AFU0sRuHm3F`4i! z+DouKcUsfIE+y^}2EMqq%V;kg8QZwYpyRqY;kt19TQVZ;GVf9d%xFa1_{4%E{ien)Ppx;SZ>mZ`w3Z z`@oegeIxo-ZpkF!<>ZCA=Ayds+>x}%NTrvnU^?*X1Q3R4pQ;X*ErQS*$!U3#h^auN zX6PA+rrV;;fV}*j6)!q$afiei2h!Sr&AYBHCEgluWNth!DMTiL;lgumpRrfV8fpq` zfAvy+R8n2`q8(e4s6uaD8`#r4SczBP;R?(SYg`&|zqXVhrfJ(vPR}+cF%!8_!yq0# zJPOWbSRyqvVkg=_1|36qf5Na~X6Ub*^D;UUs&Gb+$}~~i&wL5+oC4fFFs+N&)fgJ& zbT(MW)@wATbNx2?uc2cpFQ#*cIY=au&E5dOdaY3AOt*d-td7$o`G;vks{TJRZ>UI>UR`kZ2@tCm)y|jF zzv#;K%=JA^XNUktd+*ciUxSRgEzdiBE`M`-BZV^%I5M#>JC&#lO@mMYmb|Qo*SY2K zKd8v99SfTG-sLtn8uVd!{&SsKR)>Mbh2eIx&D#xawTz9v6(S??t#nQ;gR_e~8HVu7 zV|Htu2@2Ah?qyevMX5kud16UeH^+oHCuOdkvqohYEP~g{02lOLidn zkRK+}^Z)kbM72F4wPJ*6oA1p6P13ns3$`+9$ER3wD95<4K`@j(3Kpar)Pi9x7^!aI zrnfKr>!Wdr^Z~8K_D$8URxYXLlvGaIpZo)KuCg-JV_=p$ro<`!`=0gfl>6%+>zOVI z>&f+*c+POm@Qm1w+j_wt9)ho1Gs3T&3O^Zc=}i6|V(Qb`L3?cSr;u!SZ_II4QN(p- z&<97K@2(`ka_0&}gl+t*E0x@Ai5 zb**$JzJvknB9bk(ImID|cG9&P4(Uy@{k+ad$#V%JZ^<6*5Of@FZd#F7btd~Al8K|Uh7NoqxdC?L*nv{QBoi%1QUXqAAV&6G&+j&gcp&b zp@Tag5W;jPnO_6_qz%Bhj_TSxx60pgBkXA?;nzXprvZ&IN(zq#SfZmsvD$%4Lgm=o z^Atj}!QqM3Fi3~dez0-CO#FD9sjDYD3E2t1zLQgY&u}m!GgDn(<4#t33-L1J(q8P> z^>KqHc@!j>=E1EH2TF7VtJaa5UW)dfSO!{crR95H@9x=<k%dk* zOCD~^0s5yrOiPiO3-I#1mMMV;k_2)DL1QwNYd>6-l_&j*AnU4fZSaBNsFxxO#Hi|% zMLvMnbL-s8%sic>-Nc8_9)*v!AYIq&D_vbGuT&zbrE&A)^Ve;-e+T)TppAW}rLf~6V9(jT&2*RL%jo1Sis!o{F{;>#$| zCnRiQ;*wUBNCa9?Xrl30n^uy`IG3~}Kqz=k$mo%heR;Umwj`f?^{F>p zus%MY8))G9910bto($cmqyUe!e#%My(Oh(mrGx8vD0szb{&z&}>R zyYKHZ7xV~dDz}QOf-}A3YyW+xs4e>Y2GN`qFJJ{G&F~Xn$VNmj%aCeuF%Kk6)XdLH z5{Ww<^~RY+phgx0_~}=I#m3S}3f8m|X*h#!IG0j?Iqz5=C~HKlt;qBp zq<~HctbR5|6MH-|)No425fq@~%vf?>8*fvJDBVXLMH^G#1p~KI!tVXRmlTe)C<+3f2dQXC;&m3#95KvR z)1%5e9niEWq$u}~u?OT}-cVkC{@y1GzW>eZfBO3IzrO$F*BTvjVw5~25grJ=wY>(K z4PR39-Me3ZdH3br_kaG+KK#c|KmN_Wgass6IJU@oy4#rGP~t6ZZh#WsT=#|Z1*M#; z>Dq>BwnAW|?Dnt`a=fhOEOm#l4^g-a3q)pW4yYWokr9Lt%me;8uC^gqgUL*0hyD%zuU?;tf~ef<5kM`Q2Ar#WJC zKs6Ijg~6c}kKSk88P_FcUN8=-p}>&psCt2ADXR@DGc{L>)JEK#K>I)@(@vMuJj15V zGhfO)oxUcgtZ}=+&f~1SXD3!VG04esz_h?n0Sm+vKS^Y&+bUeW9`*=QT}exXuH?P^ zA;aj#3f)KH!qg{owv5Z}Od2}UgPdhU){o7M&RpnPHiAgoGumbvY*A^iuS`%Eod!wW zuH%}BZKc!qxNg!-cKSB5*PxO>Ja+L|Uij8c3kZ5TpUBQAM_)h))p49~YZ;Uc;O$V2 z6DWFBxdwcls1n0k=dUPpw54P^Q=?mQsPt%}Y-zR7=SF}f1K+&W@?5h3+Blw7APY|2 zte}rGNVO=>%7+G}&O`W@$Avfwah=2w$a0&eMUR}n3`y63F!5R^Z%R)yC&2XCem}@yG^)nbCIT+l` z4MzG^>Gd1m)7u&|%Wpj-TiQ|IpNyF!o&6R>m;58Ji1?O5_5kd?;8pJG!2Hee#jqNg zBo#N6>U4Gh0B1WQu&7m7!Q-0uOXg_oG`n~p=R)jUv3h>-k$ESmQ7;jaDI#CL_I*g5 zX*bWcj>%RXJYIZeChzKo!rHTBjg77m#Fl?FKRcHc;&NnNbIE0jy7i?rkimV@)|qdE zR_go80nd8fbQA5qH%MmGG|$RiQymH4rk~sNzKbUk=@DO@?tk zZ)8lc@?}T0SuCLLh}8uh)6yAxGAS8FHNChwLCsBrZS58*WPp=-sT+p4rnJ4vWFT|H zMlfHBEavVNdU5>} zZdA8YH-@4u&<2Mr)~bw^L@6nXzCY*w^N} zvcTD3){q+>6B#%Tpb8TroulVa^?ZsG)vk)*YM6;NU~P!1a)j$FGToTtb`_^_#_ULb z=ex>$eJOTQ+xL!r>(keD;VIfpzYIYGh24d!K592AQsulc&OC~O*hcI|(d$HY1tSa? zkwwS;iWextM!75$J^L$xaM5e0nAe&iR+436SBL`bRCH%ehU;yKEcD>==l6Y>JjJa_ z8^k6>r22&{zgcy6(Vz;}^s3Bs##q;&S6E~J&$R417t}>&4$j4LrK61!jeOOXIrQ3P zj0<{YWh-GQF3d3|+01Q!M%nLgZ-AgMMH=>}`7n>2l+;N#Zct4aa{Ikf-A-Vivo~ztNbR5Qa^`cDKoee6G?J_X)rxU`tUs?K8fYHmk=Agpe&-;&kM}5}`8frw8 z=82uU~Q^P|3aK4;PYjkY@(~iwBg>4kg#vrAFnPpB`Io8IlfigYm z_&dq0Z4-pmL#>9T-b%Rk=5%cqtX^Ra8|8raCastpv5IkIBMKw2%YW@34Dce(D?8e( z=U7#0IRLAJ1WsvUquZvumo8*194n%ZuKVc-QU|2jL)FYy($7y66Wwt7=A>%!waie< z1J`<}y8bB&u~mF;Kx!FLh!A)w07{hGg*U5OP7IWyshSAV)0xFWDOrh zq8z>Ts4=~T>8J9s)xcC1cB!C69&ONPQeWC+pw=RlSflZ$O_Mqr=jtcb?16!q-1uIX zNYxf?Jc6&6qm6@?X@6qNDAou-)b=OIix~03mV=GT78b}drERJ(q=ti$rGhb8$6BgY zR_hD&xC?BWy66&!0*b_2kMvqIyjwsnU+8PT?u(Th_9T9a_1dkIf?0fGwe~#OHLWq! zVJ3PVBtQz=g}LH$(t32-`%XNoZ$z3#xEAa6Y1ySX#l8`-Rep!Oe*fe3>sJQ*Ru9=n z(SkGT^z&k3J!b$UJJ9)A;vXW}~{0 z_#3dte)hL?SQfRI-dGYp)?xunWp8GwTDISb#~^gHR9JT%*or63WRqag89AIxCog2+ zOML8eWyL}S4YCXQGHZl@7uWsK=ndX5q; z-0aFy_U2lRp1XqDRzydVPTaRR=>|3h(%z7mUbZ{wu~yIC5v^f^eEr0 zs7lem2X<`Zyn!ID*y2m+GD|7J30inBxwF|`>I1I_2G!VGU8~idBF)h7t0v&LL<-+_ z+ILD{0rfg4%k4+%>a!BblO3g>Ph?6BK4R zGT<5Y%r=`BUPilH()q2Yq>{k)<_S>%LI}0AAZefPIu6q;_BU_GdVT?kx&BfVE&bl;+B{fJZlV~{b0Jj~1-5Lh zhw`W2^wW1%qLB{JRUk8&c_RmyT-pg-cgquszW7;Z^=N=|fPTA;vy2CxGH1VGPn*Bo z8QO`~#q`r~*Xc4{_I6y3H%RofQ>t?;qq)UW zmo-C}hjCKGJNpokn23#d4Nzj^%~z@Ey`|rCgY;bPH9rIe_EX*NHae+#{8Y=lg48C$ zcSNCWX0Aax0A2W{}HRlMeM%a|oop6zZPMZAr!B9)pRC&6kB+Gy#7$NAMK#KEg-1=69)^#!h%8Qp zXwZw1>k;z$a5(2oZ5M@E9RmG&WE63R%;b5Irqsv6l2#gU(*RfxY+o<+U$1;>GN?fyC5d>9ODmbSb7KQY ztF>(@H6E@@UOIGCPfXu_ZpVcxE0V*{;Z#+h%i+$(@+?_|8E<(fSYvXrP96PoE@HWe zj2bN5^#pClx%2Ro?REYf)V0~1gc5TT6fHoy;PLV0(|`HT|K;!g@8ABrzx>PJzkg|& z9kvZfS?`JuEOd@=K%G=v5$^fYs}U>X%-t&+AeY_gsa;~LRv|rln=f-*A)0wco#X9O zw`S#{kq4AFJ_xQqR|QHnGdT|_)3a)SK4fq*(>x2hiFnO$U~0|;_8G0GP`SU2Vf>~` z`c3SCnk*(cMZd{k+Kl1~%$tecAr6Ki)YH%0#n;8cjDuFGlPtN%}}mG+Fa6OkAvMB*^I-Fo3mq(xPMuMu#v&}99Ze6*YcmKup%i)!_SWNR4JAt2D%`-= zdfGhEL8lr$9y0(b&Q{Y&I}D1IGoJFhhE&wndCP5SbSDvUEKy;RQU7*u--;$ExTu8x zD{lw#$yYE34YH?Uql3EKi!8H3u#$GF=G|QIjLu8GtpcpbDT)-UOJ5!n$h`9{)%%xZ zrI2fxjWZ2uW@XMJq@Z^-xR58{LS`})oF z)ufWqT(9wbCbp~25&v%Cm~^OuYssh5$xl)}CDZiR(?sK6!%YQzE{dANQL$!Ow?L?5 z`e)sxpaK*PLL-xjX3LkVm5buc-@Cse**OtEz=ycHu6v2LI9tbOyLC+yUW@$Z9pPX< zHVfnAQ`vFJ-uDU?VP9xwz?q8mqSZoNMumrRjUDE@!CEd2oas4#)smMRJYF^UYAzqRcp^XToM#qlGbk$1I zB-e9U(6+zT5w_%TaZF&QYqL{}q2)ViKXqRYgC0vM|L$N z?OPO59K{F0it@Vir)5eF?@Gb)?p)A8E};~rg(8J|5K6}H7F6{- zpE1@WiicFU$qkSiH1zaee)Q?Abo9as79IvMCMop781KBX)YA9Rna+K_$ZDqM#c~%B z#3e{c6yF@F+v<;LUn&}2q{ZV%!gAbsY3%wswE%E4b4=P7%?fJ7Q3Z;mdNEf3Vw;YV zjxL6r6Cg)`g&}k$xFa_u7 z&O#ZyARE2qs0+>JJ{sVS{JhXNW!n_?I<99b<3;KNxuDJI2Yi~%T&`IZQ%LBPeEWB= z=<5vGB97UlC8!9Ce*dSWG=e~;P1QP7LQ{)<0J<61^*0G~b19B{2Ru=Pk##+z-8v52@i6lVfRna114Wz+n1soF-pN3j+Zv)BtPjwoyEZBd*|GtkGK=11(W|Lwp3kH7xw|MPGE z{eSrJ`bA^+Uao}^J~$oaVyK_uhZ<-e)y8-}prY-q$LkKY*G^)?@Sq7-DQED)b}=>I z%{E_Hny1sbM$tA8Ht7t_CdZoS5h9S8#{?Io_*s_O&5APN;7t#sGtE?x3s5bXO=ayd zY2M^e97_*zr+Vrcg?zbB6`SLBUZhAtq8d!H-L9Zr>H5Q}^3|C0XkEvY?X#gB69>`G z!Mj%@bwBW~JcD%{zLa38G%vTsXRrH9ynMnoPp^18jEJ(KV@OD^e-YZm+=KZXv0J#j7dei&YZyigu=q4n3dNzY_L%j1b5_)O3BX6DMO{1GhM3 z#YBaeUv1~9TWfWl+E}e_WzvRZDpxA3fc1oO&zR@Jv8jiSL z_;*7alSwBY)grFXv||AkH*iMIM3PC)G{3BEJjrg)t0dPVrmLg_z}v9(2w5{+bp~8D zTq#;T$71C%tJY|@N5x>JIbpvo4_nRWoMmE%l+dj0R_%`H%~i+{;$0qKU{(cM5npxD z`cpVkYC%wY&quA-HR8ue+H-1O7&uC6?g8IwIKDnRdo@X`84iNfRu2FK6eNwd>I8;2FOGnwo3{%n;`D@vZ0j-_s2}85%7axsGHk$)>2~Sn3l95Z8ut zpEOG`H=)bzYzcMx5Ha_+bNA>*Tf9KFpwp$B)V4ayGKeh&+E4}foEnWe+SPa5CbYOL zNGLVfMNGDV(euLKQcETd?`*nFYHlVNO6#7r3q_A1buRaZ?xe=4WSFPt&kiowY^%*= z{;CeL*9-+)ZAVW|2Qw_W20}i5@XtH4<3E=YLn@}Ks+^;>PeVVoSdm?4d$*a>Z?qVWIZSeH8fEBg5H7eXA z9c$BpafV+;!cjFIV6L*z0o}!fCCifloyetE#_Nf*o;@n3wRTF&RBN~_=x9SCLCX0M zFN?F#PD+n={%KZ1^ayvaV<11>$IHGax+8Us6Kcd#+MoZ&>#6UZ5mgGvPVLeL%A#Ve zwG?(X(v9Lq3RE_)%z-g7R`?(RhA|WkO8Fo3=$Jw(0X?GH*&;0Uf<7lw5m0P&TnUZq z{@wnH05k8nvqA#3jv{_;-z0J%_GIRR!Ct`S1|SyKs2pL3HOG`7>JntrpB zROvuLDoAfH5&uM55=F&L9dcuzlrb9ATNexdAI|Gm14G}8q+B)Y`=fo3c^uxP+Llbg z3jqPW!7MC|*En#28hzTQ>JnaeoQ8Q$lWih2vCP@_OmO=>0i`FgLW482Pvf`U0bv-> zv4qyxFA89K=~W@Mb{zJXcIIk^^T|*zRuY8|hOTu&lW|3u>aCXTHr$UuHB&i_g8y?siBWhCSrEflJC|V;$v#o=WJJ*@TPtR4h z+HM%`e@k>JUp3NnRox$2bn&W5uq&)p*D3o=7ML`&o*+tckw#TNa`*kqOXFIrLA`lsErO3haus&qo?pXG6Cr6BZc-uDN_9RwkRn_IGanK3pL(!zSZ|Wk< zYJq`n1JJUdX0V4N5li8WvejIIp@R{Pv-+$U7 zLZijo3r__0vM)wpgCqkPPg!rze_wQnsM#fXEobj9Q4a7R% z@hE>Is@|lDoPN?Cci+0*^zG}%KXf5q2I97ps5D9WvjGSwb)PJEP2%+*GV|%hsohLU z_>AnVH8Qq1ZY&&EmRXIWrs?ImDV93D&)ur90SS270^O{}ccWFThPH0bPS-JBEx9JY zPM2hQ3O)Aj5_8AhBClj0K-ze+ixwzbhbqc+X+qYCM4j{1gQ>9;G2jK`A`lIIfQ|ne z>~y0TQ4qG~B^pqZz^3bIPEq9@@80K{M$WgdJ#F4C>4xkZ0kW8Qpo(9;u~l_P}T&RgA;}589iadCkIe~B* z&$#krYgc(6kc9dUSei3M+*m`q=bp}lMGi9_)Gm}i?LX~jHMLpDjRQ8n>1KjiUCq~K ztfXUO#6i)fF~rH|zzj(8=S4-`4S=&}3c9iRT#c27HLKiPUR9rl4hm9bSxAKhUMj99 zFM=H!vWmurXV-a&$intK^Jsd^&-eopmUW7Q;UmcSggtdgMD;KEr8y=GPsS{7{^ z>I#Noh1h({PMF4_RG1&=y8H>HnINxS=@?6Vfp+?Z0CLCnW;=89CU=YiZQ|9h#Q~|; z*Dt1qNVb5CJ(n!0P(ATLX3vmVB2mwtOg+sY4`9WS-7daB+L?0=rX^bumxf^mwdUf1 z5JYJ@NVE0ef1~=wqd7Ro&yk(xIp*1{2S$G{c9en3Ne6AxhJf|Bx0#*JE7u@+8+GB& zU+{QZ1rw)}w^w2aPvt5am&VS<%?aX3Q6dn47*`uzA9@dE&XEEt^qQhL7%flS*CTh;5(#do~>*NwvtHlL@6{CBK_9 z*0&|4#Sr=dPZg}$GGufL!V>^@o!D4&`8q{{5t!6^?Co36O>HP)BBp3eH<28$(luU? zXTr0N=%2*Wx(mx{Hk@T1)>;PGG*Opnuc3Aw459>NVUPle665d)N^@xCYsViOzN)2b zZpon*H&|=O2iTND;R|fu{rJtf-$pD9we$Yn%ZqrC|IcLqnMQi;I@g#TD1e$hJmucFL+~-bj+v`t3?W z!#%yU%|daxQa`-TK~*X#tnS9gK9Oix>{);eY{Qj+m9ogkP+mG|NgzlxIG;X8U2d1V zMyQ}IcJ1YIz?y*c%L)x_mRkvo@{Dx?7QmiDL9bHyC=^uBtM1km#GFVcK@CJWeyzd2#iU{2?N_im{(&I|j4SCkZkk@hrlo5`f2RSy!RM;M?Q=g1C7l zr&1vMMY=$El(>9IVoMaGQ|?cZO>+&7{ zWpGA(UXDlnj5P(1Px%<_I!LgE*9r_{Q6uP<>8^XUXm`(Tlp`v7N2Zh$r+t#PB^Lx^ zpqCm2A)D1NWb41|TR7@9Je^#DryGVm(bwt?aaG0^i&z-x0#dS-ngt=S(x9*;qGcNT za^1IHwLzA;>_4eoSlWL}-q@PUn;?955e&Lan)=g9zI8-Da`xrD0Na*HH5rK5szGAO z%zZf}U6+J;Q3D9qGG6FhZ3>iSCbj*jsy8HGCnmN*-+bCJQZ~&+p0um*r{0RcQ zXdvXvGPKLsT=q=?r2JhKAof646#o_v3Ze8iau|J-GjZj4tfkB(ReHqYSjafbwSot^ z&DVPfwgQXuimI(I6s`8VKFf4551`tfjh%V@r7QYWsI=8iq8b58k~PAF|U9Tn$IA()=wQ@n9A-`d}L zU_=BMs^!O400K19^!|EhIh4q}qxklFOUJe2lx zmxuJ`vat@TUaETi`lXey_?kIt(9E@wyrJW!8U!_)q)t9@ht+gY)6N|9x+LFtQG@*Q zpn8!#TVQ0P;#^s$paED`LBq(>543S7lsuUR)cs_I)zdg67$L}2NaqR%wb&rjK?Vqz znQ2#d$vlcSRs$sSd(|h~xka4g$=B|ba&TENbX60*0lY3~Wg?}rFy()ST#dtii!rR0PF3lBGdki4&3B)#>AGi$kzauw?8HmoT}ZMBNx`s8ZPCCXzl|HIsoNLj z@_v1w3#jcQ$PgjV>{(9tP@K!ARvMmE++YJC@{@hI=}_{gI>&OMUB)IyNa?~gW;*0Q zFH5b)_VDBU%Ct+sVq;y0rewWQ+e6RcLr&GBWlq>dLuJ}G-;SCmtd(=Vy$y^* zW-IHh#t^Z@=*n7iUU*u%&EJqDBFx*HLz(ct1g&A6hrQM_J>1p0&T5H3A-C8%ggM>? z^(g8EQJYGFvc_X=!Q5D(>xQnD2A>%V^AWq8+j=pOmWft$7$>I@{R6Ngp{+`{6Q3@c zw>CBm(IbHESItKkHt3S68!m(xTPenWDuR;(i6KKatjDL=ur;7LzIV-P8_!zo?ym=Z z@2sMQb%v4*#3sXn?0ZDCT(eV^MogHAD-{Rj;gI(suX{wQfw=6+m%AnahmPcbNVVI< zx%{4LH+$O&1xCW0@<7aV6}h*^>e8ML)Mt7(Psbg?<6*AFf&Q6XhhPZV=mS%7N`d|` z+nJ?*DIqKM2<(o%ptji8uDZ$+P9)ONxe=3xg~M1-t(@7OIim*S!x%K3o!r2k%BuQX z!i6PWQtM5(>h{Lw75?tJrvrh=)yhT zHk=Y_gzZJ2@cGGxuQr)x(Ag)AW6yGT8j?J-NR`;qKG+)pib4ez4(gKg@k7)+Ck7GH zXXKXL=^h-07>7`+shtHUlfKU?c|)k^WsCP%o&MVNPGp~@#=|;ke2vF+*Ex*aXhBNC zwbo=!z197%V`d=LbMxYst!i7zHZJcRh8`G#bcp4Vy-|b9j09qqz%)%{RgM1_qK1y; zhDEWej)jR9 z!~>UENf9tALgmzGK6dC1WK|#gXFr)11F1$>&Md?WbGNzuUl|SYluulCJach1FYt9~ zkpUwk`KgLSKRE|viKJU~1f0wfBza{GKBy@+wZW;t)lRITHi&=s#Wd_b z5cW(#N@RAtiRl)?Y>0kg>0yu=ny&Na5~1o~XJs+L1jv;>>$;lSA*q#d{VO$UooA4xvW6>#e8+w-|6%H28T8M{J zN{B~4k(8NGY1SVvVr*K1S9xxF^|nUhlZ?NZlmq(X3P6IS18^QCB2voV1-~YRqoOQG!**Im_B0tqfc4;*JMNV`)~j74@SSb&5wrh#J+w0x;JG$W`%1sfXv=Nad<>H5;Ag9~ z*7*$ZDi@rLSc%ETay1J1ySHl585T6OW5nG-fh)veBX=W@5t0b+Duq?sxgd=*Gc;hb9u(oj**~shYdJuS~;VA zHN!l2))vQ>LO=SC#0jcR5BM5r41C93)(mIkFz=ega=B)m+KdTH)70*owdDWx-TR(w z3TaM-@5xNyr*@Cf+pp^IH{53tnu5An{hZCo1z~0frI$B7L~=VT<;Nx;^S(iTl7)?2wN9nh`r!VF2zql0arh>P?0Q zPNE}sqdw^cr3t2DcUE^9KptMQNF>2tikgTT%WJ<;Y?hDN<+1U;2cot(6Ff_=Wnip+&LNj` zRUM5}eeB`FHJYr1B%J<#t8_LtpqL8*yQ7LJ0dGtaU?8CQ8DMNwAC&uC{syP1s%a9& ziz08oto_-PXL+t4i2|<$^v$-hJE9<2M8eZhAV%|ejE$Vp2lq_5_`S~`0vasWYO2_l zuExZYb5(u7vby^zWkP7grCpaq{)U1ugFUakq$?R{TH@?d1tZf_$0;Bjv`q5<(5Z5N z@zcw%f8haaL!Jwq`}Z7ud9jmLk>s(m2&u5r}r(=Llp1iXS~0v_MZMkD`Na3l+=9B>);r zHJ25z<2R_*i@AmMwxGsx*0&s|$pWM|JdD$^lSm8MVwqnLh~8@}GdkIuClc&h$%<(a z$03D#wSjW-+^p1tj!UgV=~5$*n%7kM4NY=_hDCb|<%K`Ywc&5X)+~Nn07(9D8iKhx zU3I1S=#a;X>I^DsoWb=0hv#YUB-0zud-#G!a|8r6V`d5nw(S$?2u+37)%9>ljdaTt zmO{*{&3t7{`L!k1Ho;!6q?T2zqp8wdyZ)7TdR-UNH6p$JcVF-Na+$LA%cAhTN1FXl z=H@f^=cD?y_+X@lkJ>Di34QKC_)t9D@819R`n4-CdSVUR8kl;ZyOmThdUEaKr55KPNXE=|L}KTDXQf$s%*W0-TqNZAKu?bi0e~U z%^x~l?n4Y!0@h}Q)6%Yf>q2~2Y6JIqALd$y(ED3oR4y?2oP*LgBR&1|so5SH5ZKePPjvnJvGxUbH-9MNL`?UyKyQeTmB8-Q zEsL7T(k7Lb7qmj!JXyQ^ADXp}OUcB8ZkqLqdKm+8zX+@`nT4Vi$jcvCr93gJtljFS zR=~LgNM$rM7M%aa>t>UEr1RQNP?nP@7tDn*9Hz`;s|Qu>6!|ii z%B=RE;xg4$G{DBG)1fp~J9*=q!>UW7+LElKa|BmIo?e;ZEcLooA9@*%`Np%v^UJ7> zEfuaWwCA)Tg!)||3|ZB~m{OvNWEcY+1^dBO^C~$mnRi97qLyV@+TxM^D`znMZEC5= zNf)=;+&>xA!?{cGoAFu}85T3_5&#U*%lT+@L$ETnmm+cvN->+ESy_UmO4Df(pHOzBZ|Q=a63ztSi_!Qf zA2uGQJSAzwb0=5MTi^4cW>=;m4O0-D=%iT(j8yy%37m}^9*O6|7%BrxU7XdoYXwFm zh)b_PSIL-LqkvZ;IQ+?9=}Vd%%C}GICes~WZD(K4Lv|pwlZLQ4GdHnkmPIm{JZ@qm z-680;N*twpZH+~P@vun%(4KewDCu1()_s;ITq}+_Vi{?5 zZa;nfOTamh8XcjnmUY6L)aDgrdC~q-LN<0;t_cF18Vk^rkWO~4RfUtXtV=v5O;x$D z@q}DQv6;OVvt^@)-&|$B9Kg~7ANc%YTh3u?F}18TQ0x;imC|G^PHQO<+8cLkVTKj@ z#&!VYT=fmSTo#r8o_J=6~|oE zkYt*wS*#5#(9RnJ>u$vaFILmk%(Qzr__@jTvbGdpl=ll$3c;INye!qZA37sOMOZ2P z5gER;IDYoxPyLC1@6Z0ZzwqqE2fJDU*O$Kh<-hz_{;!Wd`o?eiO+Wn2k3M?w?ERN7 zUw`i}`rbeMhyTcjE_A;0;RnzE_y6sG`mx{pZ#D^sqgWNx>vu0d`S^GL=Ilv!}G6w{i8qdV?Xxa{SW_3_UC?Gq1BC~Yp137zxZ2!>u>oj|NNU*AHR9;gQDEi zHGloD|CQhV+kWKhAALNBb89xROUH{B&;E@c{n20b5B>6&FS~xx+Vt5+AAj@r{O;fL z-~W&Qa|kt3YjMlxLhrlLI zsd)ABW>{bPw?*;as`fBO2Hzww9vnSbG5`j(77q;*b8Zf9L1_oWD2r-+TV~KlLa7=zsd> z{);bs=}ToqUX`N1@O!@c2mj!Yeaq*4W&QIHzw{UX>;L8t{Qf`k?D^-PcZU4t?RR|j zJOAB3_HTdri~R%o#{3We(trCGfA{bDy!EHvzuWqEO`W;_I@dF&lT-WAkwbDL^fr)&$%8hvU_fFT&`Ip;CF{ zbygWoOb$U)e1f+L@Q_xzOpZUU^>$T39}<`&J4vMp3)eL#tI6T1oCB=9B$S}V&5KKTY1kX_&GxV4IO%V zNB**3`PW(JFkKRlV!(P2$%%VYM6h^Q7#9*6{BLM0u z6vRnyHF66td66T9@vWHMFIQOv zC;@hKP&oaS`0r4w$nel`Aa8{gbvh8rFhI{)emX%_(Xysbx@%jzRIwrhL$v%E1+(5d z+G&_9@)CbW#RwGT|$wbZV? zbjc8eHR56Vls*}|j2WhQEYRH>+(4HwQIuwkzEWk^vdbdvm&OAO8ugPFH!&|b`@rPv z*7nt>ofjBGrBu%~w6JmaHBkOB>DpJ*BYcO=$Q+Vvu@uR5UVH~7iK^fXM*wS(gOKzp zNjkKk=@h>fn_Fw~Agur=dE?a~?yNGbGgq4o>|$s7B4h1AL>J#iSMUKOVOola5~UC~5vjr>(Fqc|J97W<}#B!eL_7)=2kkA%su$vd32 zL~8nALss*&*UD0bj(g3dj8B>T(8`d~kjbwdR>fK*J(kPoe&9i+=^c8Y_PCZ~lK@{?fPnvS0Qq)87fqH$VRPJAUT3 zcMA3J_MNw1{L+_y`LF!P(jS&id9oNS1>uvAzxkDK`@7!u-wQ7sZ{B?P;{EUcrN87` zzWqC&??OYvR3CY8^{fH6A@|cyf5G?uf_QzvU3PrSm%sG~f9Quk{=_XWu9{CG{&hFO zTDo*+>}P!2w|0irr`EDt4}1RpJHPx_{EF}RuAle8ho1{W@VHI}Vy6?VS18UN2lFi|a~F7jf8m-uL|c?|%0^x0)_SeR1E<`MKZy1HbmyJbUqBiO@ApXT4=Y z@<6_M`SO#W{nelK{QbsLxs_L+|H7Ak^{@GL-~8m&3*TlhT**e@_x!@|bw{z`p>1g1 zef#qJ{(D4ff8Y0i{O0-RKKCJT#o_fQKkH|F zIewdCaz6sU`d#1sPygnB)|2ZaSQs*gu%&f&a(oJ9nDZRZhjw!&hJVzg1 zD(ml@(2A22h^!PEEav33hf+7lR!P0kHWBi8`lgzJ-73g>cp80Xy(W{a?aYyF^qdjC zF^U?gfK3i(qp4zjVmK>q5~}0+rxdvJ6iOw{zBIt#3+eSBjD`@2_k+;J46|!_951`c zo-)18X+SleyU1EUYg-wVY_*{x)h@cHCGC~9kPY)30_ZV`i%_NHWZLK~jS&nanjcEjoNgVX|n(HgXEm2lcsJ^-!r z$U72+EPo7kdADo%;-+QHH9i4h9iuUjyyjZ_LBF?|WqQnLk*GsqP7m*n#@Ni*YN0c2 zgoD#`rl(R$I4}*8+H?|7WmFCvc;$#9$KIGM9kay$>XnTHeS?zZv;OS)hk9NA^@NQ< zHi4(VrN8mh|2x+1F-<0sbiz3{kgX@I>o$X-FqdgfQHAm@k;4*CE^2 zKRMFV$o)5w=g)Jq7TxrNuz4uuLS%i`6hY-v2TXwk8Y2re-5{U{18v@%zoEFcJSws^ zOGplrOo%_5YXM%^yg~wO(@H9}Ev^hCKLS%S{|YD#%1>QFMgw`}DkJNIV{H*L1(Oz$ zObP%JuC_X9tJi19-2)gT8$TX^N-BZNP{wH9Wu%% zkQnROB@9QE8#B;i3ys{PZ|Eyc%PLqFOpegpC7hkBQW@0saiZjoY*!L>-VB(VAH)w* z2Qs@(Dq+^CXWW@@@Re*}lYxz;!60eGYQ+pMn0=_BKPdY=SX{EcIcOT`I>a)yaos_PnjRWjmFgv z=(gxnCM>Q+M z;Q~^w9%v;t5f0vGj;tfsinT{M{j#AUjY_S$l+bq#?ozI=UeqtUwDPb1YrpNIuYdF( z{^|dq5vH@7jF%3+=N1V@l06hAA#wFY&N=e#r$6=StB%272PT0|Qc!B*JA%4O>e}nf^|~F$etR1I?WZ60sNK@3U;A{F zRL|dLVz>`^@zB~fgKuBH@paA`PTM3@+i6C&784t7Re+oprMa2>oGIW3~@vZE|@ zRr=LWsntE}tzU>$!5uQqtC#n+(^um4dXPKot~Obb>wG_u7orq9K8br({dHnx-T* zn-Lyu9}>tzW8!|U18!=Z;#&1c%YCiivC3+keU{A|g@c32!c;4@A+VoV`E25$+hQ^U zqGS^?E7~T5IGh(vP@{1zHHm2J27ADj#zIigTa5b}rOpRXAz*j4$>PH_naQ0gg(`b- zx2`iYD69!qXKjh6hs+7-RAgE7Xbqrpjbrmj6dLy~fgela1bh@ETY#sUKTK?H*fl?~ z?ynr+R#oj}iJAgtX(X)reihb;s``(8XfgrmMU5Yx1w|Ul!6LdDw^@@}2c*yat2flV2M$EG+d7AO*M50Tn8n``YQr`kRCmYMb#Y)xtNgUM}GVG z-^C|cHOsdu+7IPj$h;&hZAyUc%ga$D3&j;_lzAa?1sjIb;S0y46A`v(ImHnV~6+sNyWX+_cE0>IoG19GEl zlLgDwD7ur~e=m?qv97%g>wTFM*ml=3V{0Z$F<(-8y#PH4)59(C#!?y%eoo=U*xg1O zZ24T@;hk-*xoCe8%+>kp&0Yha5eXX_jt~z8nP0Wj^Ly#DG59UZ+*Eo*a8{(v(6e!K ztFXnDKBm8^04;-I7@7@J`eoN3q*EV?wkBfb7W_jCGu7{|rS;3OJU@kx#lpK!GnkOVe zJ&5S)Pu+752WBLMayg4Bl3QeAIW|09shNzz8m5Gtz0#4<&-jo@hl}C-7D{>j_Y!mmjDuXMpuQuE#m4NK>5&J}if=0syXsp5sLq4PjTnVHK zA5{X@%M%9P6wOMcPzS7l(xB{kBevjET+H-JVmK)O9+}S5Y$j%lR3~f0=Cj0$<55k~ zWp>3*N`@Ny!JUJ=1|JDldd1Ry4PA4X@qlpVD9efk>|$h*Mr0|MPBCRo4qztyf&wQe z=&|)Kk(64Cx4**7EX# zpraXNNP>5$Dpvwm!2zd|g@^J~N!)3StNO?{TYY7+9;_3`&4(X;^2sM(`})^E{`i}3 zdp`EvzE|mOi=h4$S33<;De1pjzv--u(O$CTQc)&r?ej&aUcx8wZVzoXVn9)jbyj0o zFfs&;4>_a3F4Aq;lT)31ikW&gj5s?xYOisp2E{ISYvwQWpM1?V=8V{SOF!?DoS+ zr=R>(gP22pCB*+sgt7f_TOhprTe(p5v7P6o&~cE&n{K|o?9%NgpMLGV7Y%kVG+Qi| zh$)mzl~Il!0XzRPlZ9*$ddJh?RddRQ;K~K&8hG6p4kxBUg5hF`%tX_#9km)+=O}Nh zUPtCX5L}HQDLYMG)^9t^Or_S61L~;=!$)N$Qp|PavZdZ7it|JnUNM8#nGc+G*!+hh zfer7uv99Vm$srCKW63lqLtm<}#T846K7?J$=6gyyyQtDCUu@>l(B`diFh@Fs;M13G znvu~8m@4ZVttDG1Jh4$lqCPyW6StUV#I%-mR|NaKz{8)=G)%QnyH#(fp1d1Wk(i~z z%@mLGPJax8XwmC3)eTIb?#GfsX#70;;CZGKe4NVOYQyZ(WNax=6YK$1@I)1BywEHN zxm3dG;{`!(4Vcr_%Ef(&SE8md!OC3`2Ri05v^^SaaTxz)r>{+1&;`$^o#J5=vt)Z( z#eYHzPY%>D0IgelXe39toYv6ZTV&xb4ZD2MYXP)Y7b7g~DXjWJC)jz{$I1_URoN^E zlx<*IF3Q!OAihE$9DM(M|B>Qt|22nkdY4!;s)aGJEj}D|>AKNCQZHK!UpPeWTArsv z&ICR!?;-XOPKd>2w-G99>U}WGYU%5Oa-Y-h))2^VUVW0%qWNAIP#JGD@RY$aX#K%x zqoUAADu-;R2%Op?tGAj)wQcn{tvvZ$X=jTpPLL2dEpGiyhO5HmA=BD-{7}$|(~Ayx zDv?IT(-^2jBwJ=0%eaxrxiCT(kwQORHPs>ID(||o)E%2F>qH$dC}H%-Vw${kLIZgC4$Sj!#eJH{L%gdP3TKkg z#-hb;5ii*~u_|a=kyD6;Of?TmxptQ_rbsu^)$&*qVazz)Jug#915}M!k1Fd^e!l*-ps*;0_j?I;=Tm8n$h1S3xL;s($?aEn}e8ZY2197r~drOKoLW`%~s z+=#$)S#T!YU)TNy zeJXQ$3#ug0+?GIzm3os4O+uz-Apjf(6amHJSewNFM$5{zneb^S|3GBoQQE32+xtf-tT)zs%VkcI#fe2Ho(jkhvTQ5tJ3gFc_JhwYF8=K)eg|+ z-vSEtmM1e*LpIXSc%C&f zT#Jn+r+y6+Z=KW_^}HM&&T$w1Gz@6gay|h`QW>}KGn|~SuteHFI_+Q<4P9H;@+8Mx zi0pE0;aP_Z&@?0toOLperaYMLFIdH_R83`Kzs=G;8|QsSIBnAdG>y-;Yc1Z3!`7y{ zFik|M;%LenyErjKivcAaHIHNj3z(aP>w++CD3%p6z*6bF1_Y*N3|r!F!WOzlk`xU?N)`rr}Jo+itXQ zt(Oh4oj}stODn@0M@WpsvYMYg@UGjt@WaPY1@h=>1etPF$f4&h;&yf9jgL5Y4bK!$ z$N5ok+Y3d(z(aiSG15hLQV)OEU~5=QejR&kk#q`lUNqCVnB^|mEj>;M~ub8`etz1~F)g$JN-z=*=SaJTBOzEyAGTvzRQPVK_NE8a z;Vc*RP`AiZUj7N_BVYBF4w~hq%5vt3s!oB%EMFI!`tKHhLg&0H!jlbVdzEBUI7^gN z>UW|t?e6U?>hNDV)e1}E{+n}q-GctqXM0Rtf?Dh8b6OBducZwhVsruN@G=mEjVk>f zlp4chodoq_DtQ!YnF5Z$*GXE14I)-fvFlQM9Mr7(hYuN-m8}T#wupD>ox=oL7-`

Obx8hpVcT~i;K4NJCgHGpBVp1OEb5f<q{j{RkdKQdOE*(Ufk|x|h=nS=dj=D5p(xesLM=qvZBVMTK}$xrgwnsDTto+| zBcHxQnltd$8?QSx?c*OstyV*wru1^$1^5+q5Jfau5=TJp#7pUT5MMr>J4KHEvhF=- z3`TF6lt9{EXi~2{gk>FVmpvypte}>fQ(%jZH18S!^tAL8y508@YTpM&8f{3 zs$73`IkodRpJf^cf|^z&I2&(oZW*x}`HlI716h(e4I1St&wDgWr!xgDpO*94Yhx^d zFs@ZCYlXvY-uv@Lu06h}BDN~RS(cfJjg=BXiu#7ITs;Rv(lwgFdSZA1pYf@WXKU_~ zu+R@Z{i)7Wuw=g8G^~e!LHJErPkrvlua7u4hKo$%yMO|88F-y7_R9170*5FJ`D04z zT~;q=jt(GeR>|SQP|j7tckw@BVqWH`lNhE>Q7Jp=*;4~Sq39yjuEEJ?z$CB?FS0eQ zZ8kT>$abz&E__-?Q9f^#&aT;Ngg{;E*&i~)qwft0M9uace#s-ma5NVLe37-&CYOwg zSEiHC&HC%cXMMb)+TKiS_+sjm)E7MBxQHwA>~26ktJRlc_58eqXCw^_*hfZ~x3@v% z_9kSU*f`^axZbz6d&5;7XR=qC&JKC1{{ko$#S21Co=fHFlK~FzfSEPdtRdXfp{@*c zeX-kt8}t@!kg0CuD9UAXtuy-cvrY}?laHx=pd^yd&CBAdGZsO^@*3mg2${d__LF9y z#Cm`!n;$7S57sIVr&=v6=sHOkL9{;?tD#|$9mZ3d-G}~rqmx(1AyD)(VxiM<>h-B% z>8hx)@eD&{DAPLky!t&24{(JV4YQ!xWM(3HCtQ^c{8Kv$wzX3wR5tC%jn?{leod+( zZ z{B$n_jCD|6TSK>PK+y8KhPZ4*&fJEY#&}}$$4+}m%(^$gQ4!9rM})aiqIw4CS-Z?7 zFsXO?RHb#(`!DioP*PW81Y$6uoe||VxB0AB07c4>CC*gSS6O#vJliSl8yp%diMZgG zLkUiOuaIV>7LZ^(R2F#(Ilo1ZsY2HET7wo~v#}ngjkQ4KahE^|*WvrmeCrA^)=3K%`|C5ua;f@kwEqyX;8VfltxP+>{PpKlovD1 zg|-fBo076sfaD2`*-fxZox6LMo&~*h;Z}9K4pf^O4%AYDBUxNFKMf~y-y~cA;;n(o z1ha;pzJ1mA85E|D!Z1Kv--Na;Rx&XE%g;FMs)VcG3mI$cks%Wpg|6~JwYoI|xAyt8RT9H4#IVmP6 zxuAzFb@JkEPyM;T$yA)z8a)AJvfe5O+*?cKFpkv>4)(gCGsEsh^<`z3v5YXY;mBlN zdFIO4{Eu-~I&UqjEybwiRZFoF2C*@q=?wCfe9byWQLilm>SMApKrGeF6Z3jji z9z5i7W09``3!5n*K)FzobYFD=eW3T9H~qvXf5Af|1oE5#^97vxwVE_BG!MhzP88)v z+Qjk`s{!;~6g?Hc$0J$JJ1D82?u4lsGDXt8+Gat-x3wdDt*uhH5hG!e5#2BjfNTL@ zm#osHkj~l-Cp01#zi2@_Ut)E)FB<7{n&hI_za6fv1u|n?sU2%fYLvDs&hknr4NQZC zL$-9#5!pBmY7VStA~2)lHo1)e2AhpJ#kOz4mykt`8$(!3y9?OBz3D0APCjF0J#rmw z6h)gaU4XHnBI3)7X-*G@IE`ga^W&gGT5sIQy|4%Nd{|oWH|FaSEf31B-UQu9a^b5v zaqgo@0?(V`m0e)c)01xFc_^%`mB(Qj)3xKSiS^Yx=kS4Kgzu!LH)h2knGv0*sze=A zWQv1MQF@G;-i#i#$#hAA3By!FR=~bXk-TuXyB?CKelH>Ui$NZV--6 zXqO(zAT$OeXCC)z5jZtc2EL0YFol$o8yme3EsPiIIC z+SC;eS)61qXMJ(IGAIv-hW_G0=eOy6Cw9oi;Yt>fsa!$yPND|u4IMt(1F%-$(XPE} zCc<4zpK|qdsyq>yl)l=R;M&y)#Imu&)mSLj0OaCb-r!ZvwM{+^n}eYMa(RG;MFI{U zEf!#nLk%C>^(zc!YS&(Y3sohj%ov`E|AD&I5c!Tw-8S7)Yga^e#xgeYbI@ z3FR6x!=(orj+Q4n)ruCW98UtO-l^2j68Nm^VHiokgAV5W@xr>HcSh3DyPO%My)(Vx zrB>3W%~+AxB4B!LBnP0!m8U&{MwSkdHH$wT50mJ&Iye^%ZEh)OkvD0n)eVS{G;5m% zQ_~dAs@2Tk$*tvaK6M8`dU>O9Nkui5Fu~-1_jEh zlkgZ$<+8ZcXGuQr7sjd|>iKOX+{$uo8Znb@?93ejA?<_IdF=yH{;Mgd=9nn;TB6`Q zUSI>%S`7rH5`n+*Q$0U>3cvd;&1Wi@pSN*funwS5wp7Ss1z8lGL{q>0cDH(f%Sy0H zowl*AK`IATXO3#GPIwv(Z!F_^P2TnYTr!mg;*jEIA!wv*zU*j$oWPr=*^&4J+k?Ky#ayP%2veNH4R8hT1q@dvijZYgsmRZ@OFyX3qesUW9 zUz=<$G_vBc*r%aBk@s@12!^|Z9O!`kokPX}WLC79F-c%&W5lxkk|~wKt112$S!H8 zA1R0zVg}x_$3ugK*!>|ry3W6Ij2Hvee2{Rxt(HMZOS)wAn?CB(E(bQMfd=4|>o~3f zwMA+gT#jm|_IbL8P;x2wVi}TwowAOd%gHzPocy=Lff7-Gg50T*6is36RaTh1^Q_<* ziaB_NwOi66AlI|0i8hsFWfvyomrHb7RhMDUg!_LcTv%=^wwsJ#v>y@6BaXD&Pk=%< zhVIHsvLYA+ZrV=;f%#(y;Auj-Nhc2Py#M@zOjjr(>0&Z)g}97%hCKWXR>NrN5x{6)LA zSZ-(Rlvelk?9JcDIUvw(2B%{?`Fx&hHt<%ZmRF9l0 zAZ$Mg!u@&a@HQQF&52w_o7;OYp+Ff&iTFRDhjXZET8q({tOVARwbCN%@_O zWai{XgLn!WMn{2>jm)E#T1l1C*)mc;j6ge+%o#KLNj$M$>m=$W~rT-+~HEOr@1)TXE8<>fv5IJ8Q{!(wwuj) zcZ|RbL}Elr#=wJlbO+0@sc%+WZw{P>SD#pyFvMsNgsj7-eJK)*T6kR3zzv4fMGIG& z(Aot{ZtN^F&|&C71><8iw}iEuBEtl_%c(MKO(|8OS$mb~3-OmBvG1FT3=WqO)0Pes z)t;^5^At;2UR`48^we{Byvz#G^rUrYbH+6C`jv-$&-CVovu?#>MSTti?#xIjXSN2d z-3{nzmi`MupU!``uLjjs#~4aL=9>=EOq`SYQN11*g>6pDc6*J18y4^Yejr;8G!Vv} zr(AoKJXKYin+m7tK7|-~j3=9!MkBei5+1vo>@kv+_WQRjkYp#xG2MLS1c~9oXI2cw zMu9ByiZ{En=e|v6sQH>+z5u~nYzM9^aAH%I#70Cr9lMcGkHTsTt(IjA-I6tVDS;h~ zwIde`U=CH`ERZpFVfLCM9VS02k?c2P(*SGiR8ld3-Cmfbc_H zTqJ?K1rxQ^7l*7uTd4Mwrlc$*`WLW<;KiwvrS(+jy>%S0d*PusNOQ2912Wd9bCeJN!ai+eJ3z6plnW(hl8}=Y-)riNcgyh8u~njp1FgX{T*5+Ld+R^fi10 zTW13ei@xbwG-i&+v!h*jI?NEOc;=M~hf`G>V@SN^Xm{W}teAES_@Wcq5XKCOLRFJ~ zMMbebkxZ{Ha6k)7;^dPT44w zUG#~yw71(u#I8ReqBdy^?MTgNW$o6OtJe_z+;^(xlq_a|D|~XZ^Ob|QRxbS15PV7R ziOrHKQ>~s)Kwi^k^s8an2xx!MFny!MBeEU1uN`1aW%{(ZhSb?U`PoWmKl2?nA=s8q zUZgm>7=?o5wpuxuI~m?iBL;+9LM3)&h(Ha4k<^_pQP6*kECw5F!P36hDkKbNl!&n% z4m$Cem4~acD3O50#lx9G(p7~$LTSjac#0aevQ<0XwU8n(oA39%zI|nNo@ZSM%5iNd+ZRP*Y?;Y;H+XRz=nPO4E8(*_uo(2M8;s#jTkSq2MJO$-V+@ zXU=0TlPU(u$GYaPV8w8{Smk6XQ^@L{Q-eTBw9mN8+nO6Y^s=eSP0pr--At5?%$&;@ zo~Jc1$R*0}0@I>mJL(H%Q-+fkqZYN{NP&kQ4GH?@E$+>VdoaEP1S{EESvhHidzVdX z;58Uv)>A%nyc)xY#}2KG{pKzH`wjtJFB%fgw9S(K$LEAWHj#Fy&PpLes|9T^%aSDCY;@ zIt<5kb_-4Z5}a=x1jdygnUFmzmVj-J$PcqAq|=#@y7oJ{#TFXj-s>rJ1XZt+uoe^R!7!rBjdjqz^Y8igVl3B0 zbqIS+lIYyh2l4sH0YX(T{ZL;!osIetQROkvo82k(3j8{Skxhk|&cV4;Q9Wfa&$gKn z!%!d7GXr$(YAJ7#AHFp!ODLG`-g8}p46%jhIyF<^^_CVyS2FOQb;EYjyq46qjjvhl zV4b(JSyxHjJauqXRPtn{nlte*yebxp7Zk|_APaOnb)JSeOFP{`A*q-ah)n^S&4p3U z=YFSeo_08Xbm0dQPL@p-Yyug)Yc?`+79wZMJH^RB!OD0q{a7Qiz-lCxQ60H{l()!# zaEpG7UrE;*cBTf|?4@kD8WK%n)LQ>2*2inQ*_Lx-X7=M-Cp`xts4;b&)_mObvH>X4WyZK|%f0^`b$4T|~Y;KHo=~HJnGm zDsqp`v%cCW@xY!YlPD!;yMpz!owf!l`nFI|-vGDKd=8AAw3&=gLu6sU7E7?4gcWJP zrG8E-i6;jTSH4S<=7x?p0-77-VU8rXdu8o|T0}&tyxm2|${Z1uNWHgW&JfxKH{I?B zBBH74&g)87D7A``QYa-|A?5<^RH*}RjI5+R*;01Y7RXy{$$v=5NEHTs@H|=H6Dw9u ziUQ?-SGZ(j%PHq{m4y1xYdPjQm;^AHx_)6^NoO>biS8Ci(raImI3di?p+?tc#D`ib zR2S^DZH5FW3rnhK5tV2QAw${kbZ zIdjYe8m)Q?g&ox}Kb30e9p5Xr@4OL=WVg<%Ha90|T$+EXDO9d??Cf#DC+e)As;1Mt z2LL%-$&fY75UlbloEc7_Zn$uYcqiUE-E4%Xu1`K3mo3@vX2q6GheFiWT#OVMmw@Y~ zT?8BqC!(E6+QM0$6!fjQHw4wPCnSd!dxN^e^16ZJ+HDHbbbG!KkZBj8Eqg#S#yl= z??9kT002M$Nkls^$itbj0|)oJ50~Ap(Vc zK^8Fe%`(fGwsdv35EE#Pxt*KsmE;`4lC!e^BS>!~@vM_t0KX9zvLpbQAJ z%Zv{TiipC`Z6M^r_DWe5H^Yts^{Q)4&wR0rF2orW3-He2;qa}cw&sBq+#sU0S>PMTs4R>r8e3mQuZiqF+blcIpAeUxB; z3{yQ!SGoP~<0UxdRj%n2PoDKNOz%2`|-r3E;#a-i`T zSutTDn~tJi{3o@_6;qYjnq31_J%Xhmg|$G;1kT7_ABwaln0>~}uerlbAm>sqwkfG> z<6boh!`}oQ&&Ic0`r6mN@#^)b-}aR+x)7FWSNn6<=#jD+>kxIdoYsO>7JYVIiSGk6 z>-SD{jx!V7tGJ(qgRgUtDTZ5PnlNt@)>mDiB-`QV4(Bc-&t;t3x`VD5au8e!9Lifazpo1Z5OxSe(l{q{`%cN z|L(he_nQ>$$Fpy=yJGJ%@tmzA-)ORH!fKQ035YL$>$|`G{ny`T*=D*)Lnw=cmno_2 zG22#9amR;BYBkLn7a^_N*_U#)$d&Y84%dVuJWfmkoG%MRU+PQc!=;m~ZL7h)Qer(* z6NJw8AUvX3(o@|D?YM(ds*{sSZ1&D8mS{6kfyTFr8hjC(x=SPE)z;+)-c+A1xw0MC zvE|KmS426quuja%{XzxA!ViT(f3`+PhWn}0b>T{73PuI{57?v3N^7z^AE++T0rQE| z*to@XC}$Z<KlP0!z!Vv-ecJv-<~Y@KtDp2!To~Ln{jNAx`r!N) z2VCER-q_O9u25IR;&1}j7C2CYa8LnHG9)kb8rlFW^0tlRG^_7wV-ChS!t7Z zg*MLXuoz(nV{>~}8C_U;9eu4|(>yNLc3E1JjR>`+@6%sYK(HSGQ4Tk*nRy89c> z?B@_kMd_)zka3}(SUq6km#ZmEJp~PjZgoF;C3D$wamDDDlI+ISnmZ4qa^#e+iKKr> z^%_y=wjmHb_gL_?E#G);skXZTGdWCNQWCa|n;oQIdbTbfrRQEPMWqrQXD5QPFd!*S zL}%-e3%B}stjU~zRvgUb6@+PMKYbbDeQdg0rG(cj2zIv}q=W`y6j6&lU60~9%@q`P z4%Q>U&I2i_0JpuQu-}-+H)z|)I4A`p2!@au${f z@Ob3(>SCbzuG^;_tO|KFqvnRwxq$V2+*MbmW_x;6ICPnj?J*-lRop0phlP`eb5uzY z=%J``Bh!4TgsS#pgF9x8UYZTlshLt3a~Kbw6;Er%GOqhe&j$Zu7xH9NEAf)59)*-- z$mv_LPQ3-ifK}R6Cj+~l(zhA+ATdFjTmxDH=jguAsUj41VKCk4G8zs4pz3>xy) zxvv=VN@X3VCQjrWtTykU-fT`99ktoN#u*149vL_Uu7W4|#)sXT)7Gb4%GV@DIoes20yZEp;2tb<~)Sos=_j!=AMS!xS2U%sg7Di|wdATiT zl~9hqj?iydP~8ZFf{u-s*A||&DGp9<3q&Q!&x3fs)pLgfPF6T#Jc^8H*oGwkq=fNj z$Eh1R#bg$jQkC$P!FX?IIN|d}mCZN=Lm&_-uh^XX151o|{rTxo3fP@f)oM{wSCT9{{G zy6jH#Q_?VhMwo2Vw6`;@-~x0R<2dsZT_-Kq!WD}dbo();IoE7_RGolMluY4v%6c7gb~B@l zMrwD|eqYDpPEKpkuIAoVG#Yfq))VEBr%KW!Bdjn0+R67;VDzx~V4rog<%Mhmf;d!w z`loHCb~<;iRqLYH7(gg*qBsBD0xI^*@o;CezDpF*lXZljd zO%j2RNl~;CBziLhjBBVsz--nRLPl3zHMgqkD`YEdaqOYEije9ma&2(Am);ndcB8lG zibL7ZQJ9%SbPCYDpUjrMYhUenPM>vTug{*nSahJm_0qKi7c3SXP#3j1l4bGQa^Y;% z3-;_Kzu^?}0~ySf&|KqIROrg%4vRfvhf=C6N!-9$j7&&54D%)@aMPN?dDV^{&^cL- zw!)sxCwRnQ7>G^HXEXXWKXk6G5tla?)0KYi)LgdsWpvE9NYCYPBH zjmyp2fPncq^3cdXfS0smrGiZcjO(yvX1h*(K{guHbogsz^$8hjY;@ggQGSeUX+4Qf zkXuv%K@6Z|caql4v6~J{5emx#bQ^R0JX-= zSYg~EcF?HOU)SFYj?Cd`Pe)A zHO_WAhPOd+U-OfMbr_3)8jyV>tNed*f!SJdc8~^4D9o%wQ;iYQ#e((I0Tx9j<8VWI zO4>KEz>rIY580NL9n< z+noW%>_}O@Z8b0GX{=a_b{w+QtF%zaH2ZDsCzK3`3CrOM)$})d4Y2+JTHluDsin1l zU|*gh_|)o(TG8WdP)D2JcJN2}Y;6fKO^cD)I$9p=&HketzCn#g9HygjtkcZmPI(9x z6mUh-GG}-aZO3$D+^!Jw!hjP9`5s*A+nbqlIz81F=!KvZ3R|yu4#-|$Jr~YZD_p^JeGH^hjRKT>Yh$3KJ&d`7QGy#bMB?;RKz! z_SyfV=cPV|Ee86w-Ugq){qB2T{LlZ(|Fv&n|8@VwFMIv^Z@K7}c?9e`lWOwjgJ9TrEq%4>Bo2qo0Zfd=TCl)q@DhA1+;|6@ z;*FS_*J{#F{2BYyceT#?brRxoYr7h;Zn~M;UebnHf0U&3qL})@{z7$ob4M~D|g!VvfD(z6z;ns4tElM~ZY(MRwG_B2-*`J#$VZBq+ z8dmne_7wWC<>R_X`B@HACH5ZOoWE4z#1_ok=23kGUm%GS4xw|7Ocbpfca&-DvPH&j zq)0yD5%n+>$oq~W{D3bXlS*5f{fGr$Yqd@jNSz%6iIfdxMGT_X6ONS@cjrhhVpY`$ zGX=Higg@PC^S1v-5#q-vTr1j{vgbtzVXA>T5SWlZ$+f0+`JjDC#k9vZ>bXp7U)m*q zDdCkv)Xtaamyf9lkRh?UUf=i^ni1gfS|wys+J{0dpbskRd0!6(BkxO<c0LY zI{I#8y<=L~6}XP_FV=)L;FZ^+_zYxGiYTk|eQnR0GM(cDx=p0zZPaGB)Z)23X~flv=OERbw;YzjAD8mKvTsW<<*YWG=@kjIn(FNALqJk*R_=LTT^l02&D~;mGvSw zWW=mWn%Xsk+?_6hpqvC5BwQLO<`IG6NXQN3z~9~0b~r+%AsC5EfD`9I{wUFJkz-U# zU%yLPjjeP;crzMt{pot~kS8-jW21>61ofyA!+D+Ilx+!fe4+ws-eQ|2TYy2>gPo50 zNOR4q=rMK*lOvg}^*Cy9KBv*neI(4KaW%EZU)dzyfKafhN}^iU=540U+CUu@*Q-#{ zs3VIo(--;fgfJjgxTe~}Z_c2Q)yA)~5k1M5OvI$atqs_j8m?bY;#7ay-Wz*?b>$OM zaZDl=glZfbp2Dl-wBQllsp-z!EY5S?CVjhPCP_}->4QwuPg3Z${TfJtuD%|Yfm%^o zBZa-JL5hQjkFi0uobmkvhJ=xSLw9&mnTE=&mn24XuP_3}NqU$r%>4z4kz=HGy!M6O z_FAO$W&>iggMDZ^D!Io-$E4|~MZ49Hs(HSmiX}HD^n!;DiML04Kv_T=9}avH(>T|v zZfb@(#o35bnc1P9RfF{`uElmeq7t8Q?JZ#+c11f)uXp^ z3zE90YnMjs>|OZMOHF#hVhC+t0GB0?qtZfAFBDx&v)0d8viB5}P5Tc3538D8<0&|M zU=D!0($w;2;Q+JRQ1Y;iwwd)tcBIO>?R|!ggN2B^x)Ymade(z7nhJjpy7#`rH71SU zEF@I<8r)8aSaH?dR!io%(x#t>*@s~gw@8@1nz9-3NSh|@H6>{9Swsu8feeJ0AsJ!N zpg~qoOpDENqj~_fsu(7yhV-!`sjM@q8@Z~tt^Sk)xe-;%E0Twl&g1jWWV!+gl%*#t zj<&BQxE#=KKfqTShz_m)>kqy!}s~z5n8;zV`p- z#{2L7l6OA++c*9|OMTYr4@($5XfOcTWQ;2oFWHdMQ4>>&UG*|>gZ&se8{$GJ3C&$D z5oXG1Nz%z>uTAqnw+NEPBPI1+m9J_NS!sdsVwN}MxxSd8Tn+PvMatT}0MCVJRSV6T zABO>5!K@16*`V#3H12rlm`V3v{bjzp3GvnW^S)Zt|rUsQq{Du zjG+v7+T5ci;p6WVq$VlDs5rDmXJnB!Q7{jVEyN>zYXgyiGMy8igu-o0ZsqLuD@OO3 zjl#^`>{Nw>X?>W03W{aks`Q$@?R8mgf6Hq!KwPfz=+D|=(lr)4q% z_zd?D%&3`akj6k$e3qvo@U^EgwDp_<>h{{eqH&$j@?}P95^jLo<}PzPjn}E_QZyB% z+HIvMi9Fqv5#cf&wZ9e5vNPh>;{pTFZAh3JwpJ0`zWM@@wO;df&`aOpVR{%7X#Zkp z_}VOx7MF34@fn7r#LuT5BW1K1gZ7li&;-;DpM$I8QtC@@5fnm_q!n?J;1u7zQ47HajXv8xL5h(*O815RI$lo2KbZ%uX#qLcNpQ z@a(z~S~=4;Yx4^sNh^zwQZ$!KDrWi>wOnbuik&Ahg;9O5!C^^qDj~N4aMKRo!1Eb$u@+f!$2@MmJtvar1xYZyn)m$tVwF%Nr zZy^0Wp+7y^jOh+JL!!4bs_xIs8Ye_aa`sH?5B~cw*j{4j6sb-7}~A0tEENVYG;i~ zM9QuB9#}h4OK{IxLHT|!kx3DsQq;UuA7^dpFy;@KS~w@F~UG~ zogMYYyZ#N}1M}u-=SaD{^*Hj0#rggdz1`qH*^3v`Ps=#n)g(Ifsca&MlTHGG{(MO9N%{Oheta|QA z(VB0xNCo3Pe1)CzYS1F4Bm0~X;3V3ExfTFdzc@(6Ji6I>rE~F6RnsWI+*pp2r;ICI zoeOzDlk&D!B#*UqpWbZzaP5dU3t`4Eyn z*ZZHn=_xPf@tBxrTfyY6f(6peODRp~n1DC5qV za9_Qq13duIw&?aC07>DMkD2s1K-87Sc@~Blw|rrl@w}^|;;p-UA0}|tH;)V)r;%H| zK-8#IFc3X>6+z9zUCEqG=x8(^>?@-mfop2J&kb6Lra(5;gvMP|fiI-8KJz+VwZ@7x zHii`@bPPJ|p=!XTBn^+J5NnHp^$|OD_o3^V+e+6f@~F6_0b*4$USRGp%f_S}s*m?u5_e%{?Me1-6b;3#U)$&9>zQUgczt^tG!PUZYH< z`S>iy8uxGsL|tgpocW@|=^i}oC}Or!MAg znFjBA`-zD?9c{gxKuVo*7m@s0<(h72u35coh#x0$ z9nchTXJncOd;%=Z!?Z#Rf9Zvrbek!bNJGXJr$~x&5a!S%C2Gxa+RsLS)X0DhXIIA*Mnl6@WKDcKGfYc3cb>!CKFjVK-O(nf@d_PaAm^_R` zA2MiM8uVUpXPOl2{jIQJsS_-Dci+Jhy(A@1dH8#ylJM>$24w~VV%{@j+>wOy}8H0 zVH|`gao!Ah)tky%GtDXdhj=HcR_(Q#GqBdUmq=-3?xRw^iZrBwE6 zl2n5!@qqFoy_CVr;lRyj{4p*kyR{n~3hz8k-kSPJmG>D{0o{N(=W2-|t$qK$_oQ zwz34nvqNSiqj|QA4QmC5|m zJP}lss_tC&$;_VRfA(QT#H;9K0mTDTo-Ki~2aQCKGOv59IuINWybmX^6sXGY+AFs^ zxa$aMzht9!HJsxER?*TSWam)`TOX$&586cbX{EX~x8))l1koeqA86 z{dbjDK8jh;*-gP#U^|ssxbTRu-VR!5g{>0u6$fe#O^EW<%voJFZn&oPs&W4@@_)xx|xXoW|D~JS|yCLQvRO&q&G*qyllg zg;)FXGjLMWpdrMRRiPDq$E|*Kp;JZ`ucJ}2ZDgo8*@|nvr0nyzN|MV9;m;8I4s7~m zPu8+>7opf&L21LGjrD!fyN+HQIx(o$mw;8(Ktzq5c^I)+a9vRO9%8KG;#>K3q8h}> z*f#Rqg9&N})4Pvw_MZ$Sg>Z;VZl!V)ay1z;0F7JrVsF7E-X@bYn-LY6PLSnK%UFWKdE?nT~*pD&)Q- z92nfOxAYKh0u?yl(-OvpsWpTsb2x}L?C&|@K12$TkPB>zE4+7{kmAvG)IL*HVT=@eit(<#4)hf^m4OZ&WFYy4BL zYb0FBFek~KF;Z6;UzKt6aIP+#JH4Eq&7K?xHj2VYdTfg>Ht4Cs?WjH1u-fTQ#Rd$p zbUdE5%LOm>6Pv`4Lb0mDW9WwKgvVapk?^$!hLU~-_tA0L1kp4c3nDm)iVZ#oGwkL` zSyb7S6M|-}=}G}zP?l_gpYMt+L1kiykZ<*jq(`zaQv{wLV#iV>fxdlcCLj#YP0aw+bq)jQP zX*YV9Ugu>RCeJUAYo#smY>@XTfd;5-r@wO_I8u{Aa;r3hLk1H_tUw4YM|k6WyFk>e z*QZWhx$%1Xk+N zDrp7m?Aq_3IpE$$#l$q>bb{AzI4Q3|by7|xn6+p8h$Sa9Hnm*a(S_`RWp&AV^gi z%MqqJqMl{L!t9)P4<89GEC4A3AS<@K6E94r`8HzHrx_tnINoSu7U+1R$JRowEE^`$ z(=CUlcAC#$6Tz^}>h|aGP6+8Yqm0*Xv(35`iP=P}MsXjDl<}GofC#SCChD@~IH}XC7>SI3R zm$t=EoKvp#kP4R8Q{@x~RBy55qyH+}aH#9}5JJ8ejc zLy)0~b&h7}+-Vn!!_H>ApoY0TIzZ zh*mB0Qy$0;O1Pskog>&4^Qt%Y4oS+>sEqLHc0rK5d>p+|?IQ5dbbywt@4ZIFw`w|%9Jb*D_O zO>JccY^`J)yNpurz3!8)t-m|}94S#beXR##!_1NR%x4L~cx{oV+?HRUdh&#mxu6KZ zBU}}Rpw?OWjNH4O$xS@fh zsE>5lVi6PK8R}K<2uE!crk^W|_f$KF37ZQLv6@H<=u9#xaA>mCT3^6cWtH{|mjd(1 zO)ffBqYiD;pqal8N+_#phxmw@s7tJOnY2JxH1&L++v1xM&BVe`MRDHLU{WF2Xf;Ds ziC`nigh2?l%SQ<)q4$TFE?o~vWY%$5FVy$ z8KJ9?43za+2W)okHI%uj;gZtp;!{LUj_LWYyD3VRlhi!g@cSTDstkus@ zwIgUpuH_j66LalK)TFd3nlunj_oAQ?hH^2%f4+y+GMk@3;Xh z->tPOp6tF4WT43_f|ooeP(<3rM?o3`sb~sM%kNJ)FDQ< zCEr5I#5)_-YRaDv<5A(?wf#PsLwHFK51F9M-(^v|QWK2n zpURY%qc{C)K1uN~prm@FEs6IuUG~h!@raYs8UGP(AzoWo$Bj^HOWnfL;j$besn@Yt zc`Wqc$neB3Xrjer2Mc2_9iL;yN+*FEW~0akMsLYyp?v{XGW-OtHnM}|&RM73J(CE`oMlU8w2htg^K1YdM3^Iu-DuQAN`rNvlkrH!Sv5 z80}C80nbaA?r*q!Rw)j&*t58eP$~4&W`AAc(ZPw$#wvrmZSRJD;ZH|Nx5 z@FfH%$rt`pldP-GTLtngV*;ISpC4gxDREV3(X33Db;Ku7VHWjrqm3j=etH()#q`PD zg3e06`IP8vEkXh7c$s+aJM4dKr*wXLS*zPALtF#nCj;xqgU`KKEKS#U(3Fu@@v;BL z7c&M0i`4KT3}sqcns_R}UR5IB)x=RR?~4G_j6^zk6)de>?&sL7CD7;4yk z`w1)TuXDf6b4A?AtyX3QqbCyr|Ne9T_(V)Ar^9dO)tMQv^y%}TVZgQqi67a^;11#w zT~B)82osHSH{s6tr45aF(gSJ%PF)g@@dxxG4T+C92{xDcmxI|%+xC|`;Y=Zc%39^B z1b+nH5jvSqoB=WIm2k)NRxv1JJ$E_~2oxYLGLoI(1l=*?FU)Y-hHYrqIsu z*X$bRo8OoRvt}G(171-MjOHnUzJgVuWw_5|)_iyW8N!88t020h(svrQW1ucoXm--n zVB(WYYm)tpb%}?8RqsFe6xBAPZNJ0dLcnmTxlbVi9PV9S$vd>LE4LpAvAC-(9L&>{ zmDf^qR=vm#$WK1yIjzCW<9&YlTjdW&l-M@ME|EKC+TD4oZ^{$WczVDoC zt-ZHyAqj*a8bL5vQi#F2VnH#nVo|Xm`eFPKegTWd13@K_f<#dZCfQLCcZ@M1=eX9I zYraSSzpM2zckE+~=jr{v+uhpLuGa2DKRnN{Y_J_rW)}N5RvS{Dgy95r>Gd818|21a z$NbiK8K`~(^*}=zRH50Bc)n`m$`~Y5qB^y7G=3U*Yn3DIxK{>aO7;3sTb!8@2Rt%X zIf#`xi{pBYz^FPMX5KCpx9rV8mOKE6{9wSP7&ib6xJ{`P5d4;dOhS&QNOa z#HM1S9I(S9G$qf@4d9Huycpm$^m69Z;AV7H+K+3FZL;hv3I9BkNJ(zIy&YbsOf|9+ z1nC)CDw9QO&Bg0Kk2`2bsgM%s!vdHQ-hYEysy>e`2=SnmrD<>T8f)yD^WWfC%{wALpDdAjGfNpf`K$B_~cKE^*e>)jncKH zdOKR%f0Ax$YWXy-CI#KBj_?*O+TdDi`)^EEMn@sBR1KmhT8;uiF1}y z?7NArjj&lkOXaF^-M)Yr;}9NiFd6%(o^+L)PuaxL{8DnEP|5(h5dHn+@#X^ zb+=BweKf1J9TiTRT$whHx;fX-6Q9oE^R;}|L1{{CZ#&G?39upuU|u#cN6z`gxXPAz zm5>WzXTaZi!q!;kihYKPD9oCzJ2|nR^$u6O+KrXCMT4jbgf-rlfWz0yk;n2!YD9)< zYo~=}lrJbL48FB8pRot$GV1W}T28(G``Y(`#ZFuH%^>^d1axs)W-i+3Gtf($XzzUc zh8ym*+8aE$%f8JA$;1Sb;G;wk)=8BZ9o8LOyZFe7*s85{(oVy0m^&FIE=^ER;k3l`QEp}x(9NBgX($(`)P^(bqfnR|34pNy(g3n)(TPi?kwa;D za@FhL!L{nzEs;>w0TxmY$_~K@{SqR7d?vOQzMdWzQkO?XGqujOHbbmYj`)n+p_H_) z(rLw3=Q!h(WSvR!Hw=yT-nleBz1^m)PE2v_={!roQ z&yY?j8l=~N!YJC|%xla2dn87%ni-t5Jl?F}E-&kytk zfj$oQgS0LqdS-_PmKEi4Lhaf~mNyblPb613k|m(Y19I3-tH|zJds^z0ZeK_hHX4ia z74xodWUOnQz4f(xlf~gI2%Nc3)R=C2vMQgU*p_K&m&PW?G}vEw4mB0qTacF(Ox6lb z38#l0%zeJWIqSAH5W^howSs&uS`Xu%gU}<3X-#EV%BxQ=acY-t=pC)%*58H}C5iNU z#*zW2q9p*eayv1Po8yfkKia|4`@9a7OS;u)dNr;ccWT?!RsWR8iXsrb8e>zcClX9i zE_hAEOkaDNfI4H$ke!cg8=#oy^MuFZ3NAufTiVquGBQ!|Gf(n#NhK|@)9QSV(Ager zcA8W^ed=krng%&I>wU(A>|9!6ek#@w$n`{XFx?=`FPiyMiDGiSBn1@+^JQ5}s>+pt zb<)W&!5ba7#uwfhNr|5I+J_F*_e&=$Y>-8;-NRndDN9c^rsRW=kdE=w1{Q=hrHYo` zo3E*&S7C9PjQHN=SCaEV7aJB`kJh0XHnfOm8xB*~zO!!VHyt0hB%tkTb8o`U>N4!{ znp5%o<{ogVbfYNYjee_LDP%Ov-MIq*>#z-8uV|MQi(!rD%>>sMhFYwQ2_#RoD-+Gj zT4e*r^i>(&2nwN{K()T!pftl;Z=I>z&{V<5DBiAl zfAwr+#(J%`fzo+LIF`a{Ski6CEUrOVpjC!%iJlAsT0Bl!3X&JL+$OO5p=YI;WY(%p z_V6u8Lm;#mPT@_c?Li5W*kfia&0>n=TLL`QvR_aefE3;+HWq59YM+qA?0BBbCNI;x zb!6v;-8gPKx-$ye%z>l1gW9F_G=;}qr-rAl{Ew}seAy}gWN>Cv1Gd534Cf(=Q76E` zhNuRWXFzQDM}s{zedk3Ydom4~>v|kridb+bwV~3Op2lQSM?uFlw@m3>TtG>ni+|T2 zI>3jeXT3T&O=yK$)MBm9a9tf-5vB5kmR!q%R7(B75sX2xDzDQW~U?TStHG90|{DPv8!}=OE}$pw8w}zygA1#!4a& zG=}BQ%){h)$Io9r)a7!iyWByuObH#Pw~0{D+rQJ8pgiBfD`$G*#fLdZwV>y+R%=qf zD{T3`F-G00R@;8!b;+^uoG50c@MENONo+Z(Q+)dH`u$Ao`|t*@WzTx_1?bIa*5_f0 zI4u?MzRbRH4*3NTbz&MM=_krMVC?nOsIM0qW$R!q+H>|>uT_|l(CAj}l!A>aZX*~*mCe;-BaZu$)y9COu$BV15ksu4;K2@6+Bnjl zlf%%vt7eEZ296F5{fqNUa_6*5McKQw*QG{MFHj|kAm6m-Q|Z66eIKauk+Qy*vLb))UZs>GhMEB zRtYX1Yq5No&J-OXbZku$)59#FE_@jaZ9m*taCHQVdyOV_iaoiNI*JAX@+vu4 zQ}CBsBr*#rl3aq7%g+j}5%%=pARR1~Ot}-Eqh^kr0V0`@ zJU38t?IH7lt^OIGs~D^`sc8wY$vcE|Y=|fHq*K}wYhm0;Y`d)#zU#T`Pd|18|8pH> zYsj=8NV$`y04%bptfHT};pN6x9pe$p-l+Ay{mQ*_yUy;KHKrS%wVu#g&I^bx93!3M zp>ud+X&XF?YN*RfW}xMvNrH2?3+_THBYY*DCSdZ_q}?{OX|a)Na9C zKG!W6CwBhyx>WZ%;3fsV!=OijRMwynz(4;qo)0xCj6JV(b|piToW^ZiRr5m@3Pf!z zCX#JAQn^p~Oj8GVh|1(Dv4c;OO|6I6i$;7#D>vKiG7EA0MvMA%o@BQ_}zgN%gqzqFh~#2IsRa)cYo`XQC97n6?jzi`}UM{ zTCkwN*7%;iUgvYG5FVLbBB4T>gWiWLM+=Z6!GJYzI2r+f?i}3;4?Tqk*EON*{uQ+W z_mqz3c&RHUBUVj6LrEh{Cz&4Ds#?6(T8FGX!SHSV=+cWe@*{N{tm9R-rQpN7{b?W* zwVJ;F@Ux&th7%E&{q!rgdO?%R!jeuV@qqn>EXi7rp+!hl%Z{!5S_10oHH=2%fFx~o zSW_WO6)yUfHk>-Hq_tR}%;MvCuSmv*#myMml2qzuA}Pm;%1+>z>tRc$9wl?q|+t z9*9ZNZOd79mWu^m54&xdeqDXxt512jsLC2ircPG3UJnRPD^H_{G*5lnUGC6y|381n zM5CVhwIRvJ>l1VDtq3(3x}mrJ(}ecA?FgjfC=iT}Xj+qtWtE+8ltK4YvvE6twiv)kzZGO6oqiK^o{tRO zP-1`XHmQVky5cKd>t&z%-ju@fWKj+@L_B(@aAfL9!`e}p1ekAZ4FTZp5{I~BG*q|8 zE_kj_bG$k*_0ux8!(^rI^);T1y-M0UHh5y(K$XJuq@mqnO`o4iXs&Gcs5RV)U#7Ri z{Jb710aq=j9Zsl0p!bb$SgkQm>g?rdp#fy3dV>pgd{icrMgZ6JWFjnbcA=RIir+?57mTLR zY@74sYC;c}W3Mc&7)b4|CY|jV65E9C=-k@v>Y@>7ym3?L~LG zy77+lL{^UiX!}`e<@;6|+%`IiI;MINiQQ{*z`5n1s9B3otQX2oWA?t7ui_oPDrlOo zK<51gH)&S3Xj5!~m?C*z3(cVAU{1XMuJ6ZYSY=6oV{cR4dTXuvgbs=tupmv2ap;>Q zW!Qi$5P_SX0mBY!!P=T%o4!COtRr%e!7)&81f$4?E2}Sv`jcUl7_?T;Ygs0#?5Dy} zuL(I+Qq|4f5K#r1pB;5rldRCIfe`E9T z9-8Y`U`+5-ZeI(g&Z(c!{-lT-GePZCZ#-dC`%~zBUgp)tIgGv4Zg(8cpqM56wDz{Y zUiKZzV(O0wyW8gC4jXS4?kf-48}L6YAOHYB07*naRE#Ape3?7tiBt=%;&p8D)BH4N zv|}>I=w3@$bes%z3w$CKHt@}8v}n^y(Zk&ho76k%l@7yGV*WIC&|cG*=}hxC(e?&q z&B_XL`<0`VlGd|MSt(QDv9x!>6^1Q|ATOL&N=j?2pr(6ih0!tN_kXRNP?#rbeweTI zgKf?Up&yXFHyL(yaA!9FW%xYs3?hE9>;UXoZ~z5PMlzjA9RDRov}D=Wp# z7DieltWjg^2gxXBj<*yrNu~#e4R_1>b0m)dn`V46JpWXW%(0zY9)si-^0zlWmzgUU{}en{9}&5cQpJQi zyU1fwg-jqQ)a_x+;pv$9EL5w0IP#sFwGc#(Aroy~GwX-;{={WYw~I+>VDRYcG}YIh zN)L0@bTT=XE>6{S7dwiN!Mjh3cd04uZ=7a`YsD(aM@z-bs(;p&yY2QV1hsiaDUsyOBVfwpM5 zj$dPG+#6RZFOzGK^YT96m5-&=Qe4BjOPZW8KTM9D#CGOic_eX92GU8|cop4L7^UCx zf*Un_F73}i&4g?h5z-&<)KgW&lAbeK*-g8(?luCV2PBIfi&brM7Z|J;F8^?B0%h_EPPaDK3>P9-Nb6`bm+W`Sp;2oHmK&=jyOtqTLG5*%Tcpobr32}xf znUhtV9eNqujZKQMh_fVQb(JNY@syatTBc`F&LiwAJ=3n`B}PFq*Ct{_?vB(X9Ht0q zSL?P5!8%c>*Z0}cl)q-iw3ammuBJ{$yIJbe?ODTQJeX>(J99LPS#Vkf?ox+G=>?_} zxP-FSDcpQWdy`XCq6SgI?)eTD!V^VbeGOhX;^EtuAH!oo6Q`!9;IEJ532kZ0H4Jf0 zXT6c5yRWSBxH7$_aAb6c$Z6Ge+)eikKQ~x%X^$EkFVee47NFDwe=K^q_q1U(;{Xf| zDuxDWVG^B6Ky6zham7x&t3n!YY9&3GAt_6HrJ$YU`nmUtcn#6i>sI{&cWTq<;O(#C zA#@3%8eZTrTa6icktCJmB{+ZlG2QQ5*RG|xk}fT%dFxhF=(5m-W>O(1APrKSo;KR% zOcL5;bS|{hyKQ3nNMerGTrE79pPKs4{-a#>D@ms8-C8`J1v)M_C!`RGXNt-Td9h$> z70({ql8DolKd2sms~NoM0VF69Z2{Q`zr)B8j~YvIB(JYcU!@U!I=w%jYIyFh2uR>3 zIX(EgfglSwBw=TqoGpi=h4w>^^01Vz|G)e>;u3o6decSQsjZ%IMpDJ%j6SwfsgP0I z`lkH}jwR+~SCO^El-ms-lKfFP*d5KmthO74Kqq-%OkUfo^sT?}B6HW*E?W{|cZGI0 z3baG=0XQvm4WoP{g{%`;)?kUj{;*XJ)v4RpZX@lZEw%Fq1ylrD7WZ1BcE*vFwmMD% zvoaMdiKZ(%S$`})*Pyd!g>c$T$#iyA*f?0%zOKooWk5JY^{yNjqn1M4z?#&T0{OC@ z1tCfepMfz-;5P=^@ST5988n$qRr0W=3Hr2)AV+H$f}R>`IdSJCNbhfHn)USMTS_p< zTnF`xHM7&PA%}rvx+)0-pC;PLbV6s4ZLn;arijOrZc$kH`%T|^8G93}%gYTAv`uY( zO(u0aHP1E#PLTSE;iX8eW~0%^z49riy(6bD+W5Z?2->uP@(z4!iw`@wjZhq!CNNY) z#CnbtS{Ev_)gRx}bsm9T;fiYsWcTi@IVuu^-gwGji;-3#-PT&9Ev!9KYYo|&128W4 zU9CoNJY=X~;kJ!i=nJE-h=7y;vEHK59o_DtIHlSDj;j?tE^N1@0h8rKU<-5HJbo-$ zvgFtll2-DUfAmMaMe~xV%B7wGopz0CCU2&3eOLLyw{&7%wx-Hw+uAu!lBiTO?3KNf zD-snh2c9eCX)ikIEI_s8DfCIN57UzPOF7YGc8$fNte2Tnr=F#$sC!Uhc*ko~g)`Rn9scl6}cS zfIH0v{fx}tv{2=|OG6PAXP+4S{OyxbHPac;w*J`p-`8&?HW>M7O^e`aOW$=IiKt56 zZiEZAIlZA^!4<8g@R3{^E<%CnedAcVgE1hBzTFh|?^PFw>K34l7$iqkE+!?Zl`y_! z>b*Sy@7T#}T;z<^p$JYq^yy!=i%p03R^{yODwwqC$D`d}UOsmoA4;A*5$s+{@Gb22 zygl$TO&XnGihW^^FM^*tkLAEhX-9VoT`N3WpcKBNCPQqciq2fnHI_D-iPvD?+hA3! z;?&?Gw<<%)wqQ+V07E4%^6UDXWH7vFaNzMEd04~3=JEh_7}8}9TJatOVvzFf;5xB| zF>UQfPy&%4CsN;d(>t(r1fMT`?6N60Bn`XsT{t=l^ND4BcICiAOBLh0yOqK^0Q ze%vLn2B2cq25%-75q+0SY5&?+JGNT~ITI0B))g4061BBh)?1^K+bkGnI z)H>Q8in*H!5+yHsyu8NGm?KJEQxn0~E21)1>jK{eG0g@^7v1V9IbvKbZ}HezE3%`M zKUoEDSh{pw2XA2O>t05R88HbHy6DiO5T1h5) zkG-tB^Pj&dp526dH$360vumyXP1Bjz7NcfA*2?!EZv>7F*)3w9kgyhHH{P4?P|G$0h(}&c$n9=MI z_BE~Jv}c4=05Ue0i%O<~FZduJYdLqSNlttgPbP*%ag1!>qO@7;ny?WnV@Pj8fQ;TY zg!Oz-o#i&>W|Rr$-fVIcOoT&xac|e@g0{2e9WB>NBGFe#4JACC8}=6>%OK~g@_24) zp0XKGc37u9;*n6$M{hW4ksaOi?Dgu$JZSV>tEmzW>BAe|DXe5Ry+2kL5*`-h62mal z>B&S1x4TR<*@af4irbMA{Oz48Msg~D*_xk~vg~&vF#FA+)l_)w5g0-WtI~WLhM5T* zL_+eWvo#D=!qyJd=q?=wOcrfTLB~za(QRg|0}sJ*;!=N>mesY~^}+#5MT=?Hw&dT4 zkoHcTg08E)MQ)kF>^=o4QPKlfsUj23t&YoB`N>=XirY zX^Jc8>rs16+gyu-W>a7Uky3dq9+)>99gK8Zn6BT=mA7Qyq?o=2mP@N6>L$p%-fv|& z>YF_B3*uewO+}vKDl4t&jhpKOp)f2r+9Xihs$;#(5M1Erym5H!E4q{Y64ek8eGQZ$ zkM@Q?duUlI_bC(+NQ1;G(R8yN=4kJkfVsZ|na%c>s}UB46xBE)Zsq_diCfmlSGa4D zH{!`ab@y*bJcg8LFS*&)Yz!R@nb>-0AWDfXzaRF}LZAGSePr#gu>EzCIUSDB&EZp9 zOX!JauKneUy|s&|`%;`OmyzjzVRYv+CFTa74>a6xinlDWk>n1?)e=?`GK^;ojDoG4 zaSFq;6_FIrLZ)`RnEu)Tc+Cb_N1$lMm7vTYJ9o>}37nq4F@HEgT^gq@6FS4U53M-@ zW-FV^;ajyNSwcH|_gU7lUDgN(7%5)z1U(-5rrCPOic40g)N_NJJo^D?%O_1Xf zxRS1VbU%|f`MC46SP(FT>(8()8PZaLG5qj5_@K<1MgK zHMFjh<;Q>uOj~+ddM+2v*^1(zzpt-vr@e^~Cd#(hu%BPN&LC1#c^@@b{ zp-Qu&>nat36`8f>l!6_sP?~_`@|kt&TumxzM_L9W=Ox9)OwPK@s-!G;f_i*b6yVg0 zWhyD?ukR`K5rC2@XVyzl#EsH22_484zLcckdGv0moi;{FHBvmqH$+e4a zQIyuVby`h21r1e30VuaUTXD{IytCY##akYJZGb%&IR3VkBte1Wxc0m#Hw#r$8d-0sjLf9$S1G>fSW*oegqY76 zidEZfi@gTV6uo*TzXxVMd>4Cgq|(_)av|GAc7|3lEv$U`ars#kdPqOwDg&)B~?mdQl|AP6lZqvI5LGK zFjhfX=_tuhidgN}sL1+bJ3v)xZ^&|mVxpr4js5j{of!zxL0DkVbSnK{(8R(?51(B3 z?Pv(A>GnWoRB1`w=uM&o!&0dkOm(a8&3lOCXEWXOQqOK`R2yjIr*WoaO!I^2QL@qI zac1fcK^ABzoAlOxQ+2--LPuab5DT$)HgS_(FWpRoSj!1xcY)^ZT|wAsiU6z$rFGX- z9ry|}HO;pd33SW^F75!H>IX1Y!Vlz1lD?=?yF$X$JDhsp%@r4P_)k}PMK6yHOSLkz zqIy@%(-5CB7^Y_|n8pQ8Z##LAI3%ozW?-KvG9guv8ylfyHg$TIhcL69p62i{NSZD~ zbut*5guv=2NEe$DWiwES#Z2_a2Gj}es3h3)ZNs3mN@^Ge%NgJ_roB|*mQ1k~sx5jP z&(#uCkX+N2SZUw0_-mOJboJA0SIxj;m(o~LRu}5G9&5&EBaih2+Cs84Z}fQxS#qIj zKe2TXor9V=)zE!l@!KIMG^g>}C2Wa_Z;b-zqvQ=7BiVg_fp@0SP@aUabY|*V*B4(y zqCZLov9N0Uv}<@b@dx?IvEhLG+>G3OE-~nxzfAM z1zelvGEF?A1d;(`k6QF8gE(x(XiEpJt_erxltZX#Tzt}bCZw+Nj)GtX=_i3qS40wX)geLOU{CXOXx|+RD;C z*C*ILnGDe%O;$xYN~9gib2P2Wq;7eL##V9Fw!5Jtaa=Wpf9^Y+$|YgT#0J)m>w$+~musLcXjnoKcG^v6!t}M%w(`UiAyKFdcsuW9rtvn}x=dy==!CrAWvf$#>V&RZ z4}TgdTkkc-#C9|-i9?g|A-!M>?WSkv*(^wu6>2Yzu|qJ z2%kV(3C+*Mvsw%~(wBuoPgx3P(g-6)<%Be6K&e`Y6Qf>(9dR@}aJj z%+I<`@9WcM<@k7c3IET(z5Dd_$`aK1qnb)37}<;DgCthCu#MjTiUt-)+r=;Os@$#B3q#>D>VCPANX;^MoV_V_7;@EYl z{vTBU+D~ST?AA>fr$=EE*gBWdFbO4;%oLpU`o1I#W`(BI3VR|t!@JIM{#}Fi%NGF( z{%+8+b8Ljnbay>ApDo5Rp##SOr_D?=D9croj)d1M z23z;(bA|aAw9cVD-!zbVsE@n+Pz*=Qr~SdLqKw6-|7iGq@FC$eSgs%U2a{ z$Lf;+caFvS;|e?ENEW(j)2hYyJEbjaYr5w)n{4`S?h*HRr#dG*cvSS!+Kcnj_g2`t z=*12}x3D0<`xF*qgbs5qK(*Dddmu8WhwOTMuF-Q=b_XwNd}G3tLR&Oaa{gwki(kFw zL(?TTQrY=)XukjM%cqYYKmO)J-)dM;_#R{*-b;QMMjUQxxgiG7aiZ%k&^Rk=#UsJO zi1Qj)AdYZz;`*7)9+wyU_}Em62)j;F_8B$D;G@ zyDf`5Ir(11NTp#(o~X-bI)(2H48;bQ4zLtDIa3adY&|R2`Hm|108^|!-D&T$nntAp zW(gYu<$JZgI0#<&#J&Yh_djc<<4Eb{LAd!iMxtS+%TUap4GG z+cOUExcx2}XzRu=nf6B}rsfxya6?}R>dRssA+>Rim>OEE?{zHdt1DWNYm(kNK!wAy z?7b!$>`a^L(!eYxN$N-$p`?K;>~eLcHJgpru?XrY$i2z~<6go%%BJ>+ZKKl3f7wzo zFy*CBCJXiQr9wAFYl8#s*&VUB{a$pe}PG-}f|L#|_Niin1WGAPT#pv{a{J@36l)f43~9qn6K z2l{k|RFt{%bTN{b=QU2K{vc9g(1id@@<-p425Iej0s0tTpz^dqA{QQ_64Ci0x|(*f z*jV?3XQYx0Q3{s12^u;af{=F`DL%b?_|@l^zx(yw-~RgYcfY*+?Qh?H)j8GYcfZQ& zUGBGQ}Q~8o>P5C|e^Ya4z<@MJu-@fnUFbui8oVwGS zhOB)uc6{QeM<+U^`MU2`Sr7U8t-G%o_--rJT18YRqV?xylul&XueCrRQ}&ZFnD-k; z{X>*~E`mYL(~LG2{AQ`O(Urr}p~0}75t292>#5_{*G`H`E#nxNE@`#pV5j$`V;XAQ zM=i>Ps_~pmqSfYWTuDByRE4N}k|jyuzA@?ySa zS{Xl?k0sT|U;a}~8sC5aw@mJ@zqL*m7@e2JoCcr<@n4bdmNMs(AO})#dvvFfM{cCL z!nUQlj84E4#nsA~DM_I*ydPLMs4c!7Z7L_9>|w6c_C!xUeiWl`k~~FbBN~&dNddfvQS(z|A%nM z21ppCKAW9M>7RXXc&e@qk%Hn zfZ{51zf+u+1J4ssOrl~iH`l@QEs9HNy_z@3>qtDu600uc=F~_uveW-+6N*%F8$9Oo!Q*-QErY> zoltq{0khXOqrg2cvG^V&u{QM0aV}&t<$Xd!=veIUE*UV%nO@qa*tn+$S>wyz!11<1V>Fr;%v2=CL@jesFDk$bTW*Zafgwa? zba$EDxVHrr| zJ~!DShgT_SpGxc%GyyLA*sflc+8crbiOb8E8dot6lgi%)D17Qxn~AoM2W>L>#vt4| zGix>BY!W(-OPQB2p{SSj3*5aCJiEQ;I4~YWj-p19e)xBty$CI~wVS^* z3b9lg6FLPbv9=S``BETQth`4h%hgaVRwT11O{zuLp-uT{LVm3_Ixtuavc{*w*>0^7 zNQ1)GnStrY$;mUsFrA5fi`D_z5Ix>GqpK!T8!=9x#*IDWZHHn(x@pUJgAu^AICU%} zUp@)a*Z05t{Q5UPzx=h&FTeNs_4hx${y$${{?5zGueS)!5_iJlFNpIRHple=-?8k( zBa@*h4-J6;`N3?zosX>rPWn1XL8(n&%~|B$?TByaWG?68adfViQ$!njT!n4Of+i_|~9m_R30(V^Q zX0JF6x={hbqzRw6nt|~?ud`K3+y$f5ZPW@5sWu|DmvZl3ezdepUdL>R>~z;>d}NFK z77HwDI=i839hi7Od%}oc_M@{pqg99TP!n23XVqH-&liW&z^#)d9RQENR!G6V9eR;z zKM!h2Gyj;Ir|Sq;QZXrKm1MAk!_;egelrq%3i}~9O~q6n$zf`nBziH>fw=R=O?g^$ z*}Jo>kH2{T-Jkypf8($J{y+SW|HFUs$CqxF{=BcXH3uyxd(Wb?(;=D(3%~)Yu8zf3 zq~L}vg~nZjA&|kj2tk{Ixd4^slc}U3rNb$VXqivX$f~*gr^j8{gtGsHF>TP-#+1aw0OdHeGMW7(kpA&S1I$z+@h)xa3}cAB68KMq@} zq^rXO2Vg4LiQ59Sz4RWQsRwq+pRRbTPwT?WSXZ={tP6HBvYeUX=OyVn6-%gLY6mlo z-deJnNH<}&RWiJjB%GnO(>K+m!MG|wV5Gl#>bKUavzWvK=}GLz9wl1U!NZ@0{E>3) zb~Pmm{XR1{@!Ga}oqCe{ zM;J$di{0guMQy`un|WvK1t2Z$ZVhA@!qsq5KR5VRiKU)UWno7(1-A;ANuqn#T@tv zA|M_iTBoftRfq^j40trI*(fkuSf>hulj2}2K#zIvWVM>>+xrLv)~B(;B-7dm(iF7| zT9ugT=caoF;cH7z%E9L1JD*Po3Et#W(KdKYsve$H1En5ys(Y_xA0bFlYh_LDE|Ou3cs){-JLGj_^jd zikByk9{bCKpryqlc?~aRpth;6hhZh&fk+~;zGMi5BsNz8!I13B>E*L(aj&Y#tkA4H z?9$n%``kp5FB!QYKY3OSTbGvLRw+M{%$X9V*JN6yiUfwEq_2!e^?1(9GP>mSz2RIURaOAj zo?v&XBnyG)8!{?a;@?Z$sd#DSaz4P$)-aeWc{{*)c>_ea(A=AP@YCN(cZ)48wxzO$ z(*c!0bP}dN>^r7wQ&NnM+P9PH*48~+l{Z5;ZxiQ!XYmM2rvz&joBR1FjhQs;FZFnx zny$xJ&{gU<B@$sotsYvrQrW0)2qre#0 zzJU=bDg*~$!>|KNcqhH+u!o+qM8+Bp*h1tR*R26N$zq<|7KYqA8Z0(GgY%L;GiB!Xwkz;AseXtS> zt8OZ`a%$5I0<(F8-#r>*=%_+-InqM?2ydh{;BcHr<-t^O-DvLAUunWB8? zGT}e?^!nF6zWkNX?|$#o%kO`C{YNjaor83@r+(E4`kniG!yKc9wMIvfe0;{`BH+4= zBuGfgG;0JxjMCA67@ocE3c{b^sw}Q)?@`ZO__BvUWTPovTI#C_84Hn$`D2PENbQQcCOJp$MkL>D{cVA$Xby^~f??i=4LsLd zCSuP?Uo3t9{dd3ffBkQN?=SzaUta&CKl1JCFB9JQt8awC2oxu~raA($)AY7=T2*4h zp(7`)nBi~97k&eA_E#?7VG(=is|h8xWkJ7X$Zfr&q~Noj3GHOZ>$mQ5yAi2WXetwj zH4u{0#h8~}spU8ImS^3-fBm~3U;g)BzxA(IIV8KXO_`6bL2Jcrt z8>Fmuhd&)DS&EoV@<7Meg!0cVIRYJ#sehv*na(3kQOoS!GK>}R$e9|#m69jbm`kY{ zmk)sW*$kc7@WD2tRGI;tU0vWjjD+f;+JJ&@WYnbL~DZ?V9h97o2I=~jE4+igJStn=$)Um94$w* zMr;wB`3EphjSNQ`qXaGlSUy&DH1N7TrPn?%_q7KdJ6C&d#0_YuAj5MD_jSN* z$)1chwTgdwQOdtN8vo?wM^J(eh8-P=Mr>Hcwx6~$RrXU&69?1V*h!Iej{_pY@b>e&X^~d)8hd;K&VU$`}rns9Krzt|Xlmg|l}f zXq03C=W}k40Lrig(3OUfmLw#RF!sbH4Erp8KH(?K31{n^nRQTsKxg{$E4^;4RC{zP z5dp(1Uy$?OXkp#9lRd7o1?rTfC(-0RM)Db?~GMLB3q+#9l5i7pIro#sS(sb8c;pse_79zPO1gGVwMMCrao(^L@-i~OxshlkO9z3fClQ!VAm*jYHsi6!i2#vheUDu0<_usoEMSrdz zWA$Y@S-$zV^$S*V3c=>mbPhPKjEzSB2u|};d2XLh9Jj6QQ_ty#-XhKUfJ*SBrl#Pa zpvcsMl}!(_Cb=C4rfIU-&a-1fR7zB{q^}dm!#Km-nRh%&(q=UE2yTw4j-wnLb&+4~ zWYZr2=emo7c&EMvUwTd3p-BLBC2%vY&o3mL+JL3{r_;+IaSR-&kMN)-kytuMywQ($ zrFzBkFkPTjVwB9T;hz_Cs+~1Vd0twSbnN~mlE$yY!3d{sv6is)vMtwAVVT~^LgQhb z-P)wgh5*=LHW)-xVQQ3~Nu8$jp*_AzPf;xAEOhBsobQ%0#kMO`zxwj-Z~ge~Fa7ZH z-+zAnKVDw{LEi+E2oH)<3+^c@XX}@YaxzY0)rgVF)(CS1nmOBBm}9}0FLSuf7~ItX z3OdUuR=uH#cj->5jsEhhxR^)?x4(;JT1EGTr8t+u0Jd|pWOiG4wB*_`o%x@p+LU~? zqiXyVR|M=#XP!}%^5YIgam1KxaH>|ze%6+^^2!Agqhg--?7IHYt}$?20ou63tKpm= z;T)8t{J ze^d-mxA00&;y18S7RDp7@+?@f@MpQfLAon4;%)f7lA0J0RvQzCFT5P@z&7WW<#pn9 zlLK0feq9Gyp(ey@SIlwJFU?j6`bV~v{G>AJdQv4}NDcZ~ zQWsMt9ltHA7M@78AaASrcOUM8fiQ8|KU%Fk6T5R85R2>Gv8nat03YW=!RXS6 z+BZ*Sk#kk?aYZ?BZF4^5y>}2a=@!tWtIb*(s%ujbzJVw|`0S){ZYLK+(0c0UIo(1% zs=2nji3W?axo=HXdvslL)VPC3k0(SYxib&}DFVCx$I2Rl$1@l%J8RIMNJ&K@t49iT zc`N_`=T~QAUq4zvb7=?`g6lUADo_C65+$|kMi6TX6ffJe?{LwXHdT&UG*mUa6(r|= zEo+O5oav>SBI~NEW!dPF+xaO~p7T$|DT_T%skJxN^TB7yQsCY%LvH`#s3ISUoT{dM zOFc-tZlJ41NG}z@kaCJUQ8eCZo_5gBxmvp>wIZ%piy6?jbdVL8%{)D|?J#{?+pKak zPv6s_z*S!Hj_Xn-OHRdWXEd7~6wo4k zW{!yM^uySq*^G$g^M{eD=;{O#Mj_IIs|&(9%RH0P9vQ2raw-t zyYK(tsbg@Yxh%bmVmlM4cvd_-LeVkTc@3T2eC{gM=)$8WHhn?5G!L3$rl(}6{Z6_- zJx#LHGN`ceztTF+=Y|5o4Wdv=D)gl4LgQkwbgIdxt_K$9+QD1^$&?umAIx*Wd3+QO>lE>8oGBGudA(r!{WVG)40s z2CKSzpkRYcc32&NotNaWmA6LgL@_``Y0rYqIN6XS$urqOjzA&#xw%RBv?{2kO)p#= zc^xcGcU3~UbgaA`Rx01a*zE!bTEgB5awoZI2*XS-4cn_q6oVK>Jtfg|u#9?|EE@18 zv3{c&Gf#(JjRBaO&N{qdm@9ngrM6B0x!`qHln#NoD?B+v?3i4=m~Zv2DNU#l2h%hc z#|CGo+>AqypR2FRfWs{Dt3=)7-5{;|uZzV$iiWFjGO^z!Gi$4vp0jy&H@F1lLLUTf zr%*n!&`b0lu;oJ~`9S8Bw$pr_Aj!7b@m}L~GnFaeSU+{HriPdhvc8t~<&y`5Q#QLY zZL+{-2a@A`9Qq|M<&7d~-V`9zIcoD92JtO-vh$U(=#QYTY(g09$rB@R4UZ@^4lqbM3 zWx?+468>U*)kIXUY1y=3jIUmlCuU(_bOV5ra?DJ+?lT+3M zwxqVcY7FPprqcYln<4X(?H<@N=H^mPuGB%_y18kA?!XLU9h4wlJ5|VbUpWxUW>HI= zTJgkC)@on_w@%$|YaSOh1nUW8jd~=;G8Uh$2=|zck!W&_JPa>@=zK;(Wr2Zhr-)sr z+}EExJ?9x#cTY7&T_TNv^Qm1?>FmR0?ABqg`+AQGGZU;}uDe;zC(4Q#c&n;7%(CNl z!U<9>OtL26N~dZIJQB6?YQXSLm)8zj5MZ!Y*3#*T#}rP6%f6vUA9lW-CI2<8r${c1 z5=vq2*4%{NKpC{IPSw0TaqX$Sc36Mt@bp54hX52dHnZAGRV-1Uxg8ns*~B35ZS2;}S*YPUxdvOsQzT&pB&h9fx#)aG?`Zt03F`ve zUO1PM4D~F7Mv>2M6Sv- zbv9JHyz6>IBbQb&5p}wO)9-%a-+*o~LPXCxawv@)q^e)xUgRp6&fo}t$dR-0YBEDp z2Embp_k_wt`A3)Xvur3)bDE7no04mQwnE)N%tXJgH&rOD>La&?rnvq&wMC|n98*!S z#Yj5TaN{tXV+qY^4#`d-GGW-s>)n-FN{tOZ98~V5$+KA3n=;CsD0%iWwtv6TxBXym zc6VYR6$O!7f+fui>>Nvn5`Q(CW>E-SVg^7?&4@C4az`>CvR1Q2LzR=1T%6t3Nsf^x z^_25>eb=}_|M28^RmKyk-ptWlBO8JQ)kn;*XZPoLlaop&EX zTN=BZlpQL?C#Yrr%=Ds+*|gC&F;RmD%}d;8{GIQOFukV~bFNC3|ALz;$_nCe zr%v`NoJW$k!+ELHvwT9nv&ULo3du_p^*%>~H4O@3cVG(VS;nE8g;w*a$sB1%z+s@34@}M1{0NJcaz~ z;7W2`3I(GfD_boWKYjP%n6{x~U0UY6s(CE|WHn_OS}hGxwB)oM)ilEK^PY!ukiu zf(rYC#%0zqY$_a>S*NS^YL1pP*JEm=-H4J`57a_>g%^KJR@ugLEk#Xg0?5Aku_i_+ zCo*%wke!4(hg>Ri3rLNrxV04AG?Q9`Zj|u$!CRa zAPH%IG<2}D0u|gc=O`byxEkI&45yfPt z-Rq2}v$pq|w+v^=h=v#4$ogcTxbt67)z>cT_?fi!Q`fQasda|6wP!MuX;I^@&^hnC zBB*T7_p;w2$snn4#nS>w4<+=NQ|>u2YYSHoo@{}YN7@IlGwm9rqc`y^va*H2vSVrO zx?=UtUuMfG*D})hDl@l1Xl;?1qSfW}l%M+%uyf+fjJ%B_IxHs4iH2&Hb-XNI)<((W z((1=iPBypR(|%DL6=J_ckLFf^eegrFm0h9oHyvXvcXHjzyEUR({t0Z{iC2JTJW9hb zb`$FUa)*4=1Ph&wtV<-Kl?Q&(C#E?mZ*O#$1D3G;leX5SLkbZmzkKRa+`I33(rlf1 z|In%OHbFJswP;AuT&PJwbl<1~C9ccR*98lf`?cDOZMm7%_UZ*_3{^gh+Hi`u1<97( zNiL+2$M?;69zSsMm#O|-fItn~X{sc2Up{s}uupP2lc6dBpC~WUzVx38$Gq|}4%}49 zzGz<$qeiJm^xl2~5s~1ktX0y&0pv;~AVXe?L`+R88H7!;D>#KFPcno_$sA#xZL0$a zwH0y+%&)3C7&c70P~Ic29%puqeF5N@mzn%1BQiPn^2W(VZHFAf4dYXSp_VVjzg`|=$ygltd=$f&KQ(gR zRK&Wz7(-8Z5}^Aibk|No5UOb?+k5;nZd2&E2a1e9QY%LusDQamRLeC9T!%wjRbD%% z3REt{eVm!X<&U^CHni;RkU9eD8(kn#e!oidW1b}VY*E?WLSv}{)4}+S%YaW!S)^fo z_0H{sN3iqKI{G-@<&k5)@}V-$f{3RIT&t&sYYC)0ItyT9MK|TT>6g4kZ7C(&^V{iS z@1r_BV%(;YJWFSvwvwx0BSeYO#n4v;L`2H{d@a@n2@bxczefNL+Ck>uXs!!ej ztj}%`%{YbnnTaBIseT?-Rp8|RLRn${IBW$R7D!xZ1O#r2o+X83M@ntA-Z>9OlI*EY zXO(i8eeDwLJ&HDm>1efFWv{}wBQK_=RkRb`T4iyO=KuAd1eb8xfG07!N6Vrj9gQA3 z81uSDd()IBU$=WI_#i={+E7^0U`$ty#M@GEm90ngYLvVvt6a!HAoyLi?^Q&E#>2diekVKmbWZ zK~xR3{G(Ij0t2uT{XJ!7^uU{Tt3f7#$~1Uva9mriJZk1j`+~Q)JNwcU@F{7OT|J%2 zl>%L`ErE<0E6MPZBmphsXBhv$jNI5#N|V=bKYsr8zy5Fj_K!dQ;&Wd>nju#rAZG>U zna)V42fl1F8=bKsC-VfZ+a`<}AQpXS`NWhf4rRw4i-&Z&jq?qV?uTcx9LAT*v;a_q zIhwg*TJ16AmAlDL09vg&OjXfnebC`#C>RxC@Lq(ps&0W=pZY~szrOyXUw-?uKYaO% zzy11e{nq>w^fDN5s;X5lTbPki)@bA34OKZ(hHzir35#~E=cj6`EeNOLv$KOln5^nz02typt26Q&YK(|PSk$#k17`QgH3EC_Fd{@ zVRZE{VgF|$xHQ#&nl;7=ES#aMrKE&1-Rjm{FC1fk$;T#wYGA>Jpc|g3P(q+S4Y<~3 z?u-Yb>O{DzeZDidH?nKk$BQg?90V_dE1wq2pe_h?N>gpX@X;w>&gQdqTgHHbM|?+M zYxlZG3DDxWZrl%Ywu~7dc?NTIRbIm)=X`OU%5+{9eQGh(y1y@U>7KBGC{sL03gY>a zIdrkDruY1M=BvxIZ?+aJr6c>;ecw3J_Z6i~SL`AKkad?L-U$e7XzN=g`jn1NXZWtSZV^}jb!QPur;Uky z+7K=p2GCqnOUc`+cJim= zkK4=;YYE&ZZ)qQ9m3mM9DjeQ4%;ju^IRh3txA7#aHY+&|J^e-Xh7 z=9ID#FD9f*`;o>3UU)9QeG-p)4w!Z;&qL##TE|v>&FfN7O3m$wvtH9J zZ*-U2s0z@jq;$tr(BfUey*t0kWXh?UKvm6E`^@KtCgn`>u|{bd;hic#yi7ZK>uBJ1 zVpCXU^@h&W4FC;idA6ndw3Vo~6wB83(3+tJkoDY|z5LYab9W-BE6j_mEJA52jErJ$6jTPy5>0Mo5%ZKX2Z^ScZ=7vM#Yro0*Y_L)HQL{=WD#^P~oAojhwo3Dq z1I|X$ytq~J>63p;&{e`9)CJ#t_x{WKzwxV=|LT|D{@X7v|EG!lTi?Q;4TNXX@FP?$ z!9(H}L*qgQKisoLz(r_^4Kx6^THmx3HDh8}L@;xma_C`8KHV>XhM*mDLQ+f<-#ct8 z>vQI(JvuuEuZAj;3qpD@7EMOajCGLEsxFgcQDaqEBR^tMPDF)OY-I+)jki>$(_6KD zqsM>v$fZ5?FpmOIDDM^UL9GIi2aj7qXwKEP3 z&65>^Zmhs(wz8%of>6};3YkSkgl|wc$1D2$Pye%Ux&Uu-(ROHR+WCJ&?p>!25i9|s z+1!iWG%E2TfpfI9b_H=RH8kfuRK~Fi{p?H#_FKYrU^42e0sw_Ay=A7E4XEu?Y zp25pV7#n05--b{4BztjKcc=pCN=g72S?;a=Y;Rfdrjx3lb?N8Jhrj<1zx}7b{_;Qk z?z{isXYc=HOWH;{(e}(a8*OrUWH=ccq-qvxNL4QTvE{lzU^rJ@Agi4ty(xZ33+Gh; z*e>yPf*th23Z;@GnHi0JFd)#lA40PcJ<2-rr=`~`>Bx^9N!X^tGiUJWzz?Mh$1J6G zLFXf5Yosv6n>z}bRcYlg5VK(1aZ9}h6$->xVXqu8dJI#x>NZ`?w^q|yBGtCg*TTmI zTVaMQA-tbkB(~asA102nkBJc>7ow;vS(=7v7>xdNZx>EVi_-pc=MPj^if!gNb%0Sg z#;Hcs^vu*5#A5cBb!@k9?8x?Ve8YtL8jsv~$!HG)Zn5}$$}4^Ru9L2(oD=$TI=XDX zZv1!`I}vvg3z2WgXuRm21|!*?nL1JskcgY>o~i2~Co5=O-ho-{l3;^Z4%S5H!NtsN zUV+}<5&t^Dtx>Lu`ukjXotuXUzt zQA2?Rcp*pua}uB&3st9ieZHZuTHzGp-2`?8aImr6ew4>rJm08c3fSO9_@h-B})OW>6^iz=nQpgV2u#OC2{iMDdGkaS@?V)D*ap1u{vUB{=KoXN3m z6UL?(!hHto;4_L|?oo2mitSS}@# zs&eT3^-BLUB&yuD^r%X8uf4$SC0QdzX(fDAm1fy*l}HkVWOjusXk3HDKjo-pS+!PK zNownRRGHBuGN&rE=atsf89)w4FnyJBC&P(pVB|1<@ykgULtOU&wkEOKkE>mhe|ppD zmxVPYmA(Q}i)@7fs`aI;)JS2nfXIP*J#kU@%*9;L+?JR#N|8v?FC>S2J6M}$`tPJAWr;|I zI4ukHYlLaX5Z9w=nG|pCL6L8>JcjG(h51KN;{cC@T5)Z ztaH{##(MVTG|HJ&hfL)uqHK(3CMb?r@!pP~FbsbM#ZLFfo0=RUn0Gd?!)UsI1G|e{kw&^lw8QOH_F_@hknVFBq-bG)qkLQYj#L1;>9SP3#A!eIImY`C5VyhQ%XGJLMC*N-}mB zNlFSwsokk!%>yamDXxAp`cwx~KPa^C>LhVSVQQbl#hvAjbk&g>|HrxN>E3t9_fJ0G z{p|DW@BO{6|H*H^{%gPU;m^2L=o=+Us_YFG6n^!Hl>ywA)d!(HXXg+QQQKxUo>U!j zF3__PeALrY4g%59@=QZz8?g4b1rB#Qv7-7H9l3L$7qseo;!oUGVY zR~#i)U{q6*-PNT3!PeWfK$@6XT0Xc?YYkI}l-ooO4-E{dS&+Lv#{Ct9JZq_F+57qZ zcm4m>`34C1REatCX)upST?l7>pUfT^g|8t#qw#w4>w|wDq=y2Dn{?f^T?fun33QRg z{URNoP-JwoOP0XiPV-*2wV9S>lng_p^AcOT9{fyorr1+$MzV;Zm9}LfM!I|P{rA+< zA*pI-LG1nQxF$|s+$amvM#<$j82Zj_iRAnGVC&1qw4t6Rlee2@A#W@TMA|u7sJ3Jb za+=|u-64d>Dq*gcQbG)rn-!V|`H=9x1HNqpcX1Oi_A3{qTkkk3Je;YwR7SkknS-Vl z{*K!!`loX-Rwk`v+8|gOw0@TBnzt$*#H2ECKeJ=hE-0PkgQ8b1j^$U^b({!zK(wtp zMv}CvAN@&2jRu_`ATdZ3o4Mqu@ zd~nkp^IBW(PmylnqI=daXJ`ImgH9tuFYRo>w|}=#apJS@qv2f?r)%_sBl+2FuZp^2 zF-v)8bk367wVd%3V%*4!{e(Yk#jbWMM^XDT?9O!Z^FL2Y)WA$Tqh?}>r#E3Co!QN= zPE^`$esIHTM*GZdP)B?7(P+l^vb+jO-hR@axj@>OS!_HW?1IHYGg^%ji-m^VrlGh< zz%9m_*1BX69qS;;w!e5^o`dM; z;n*`~ou}_ol(DV|_szgTaMqd+@!6>%)y;QCC;;ioZt5`@E)5-cDxuUWOf`M>OQ#wc z{ltA4m~04j^B}C}(ns}Dv%O7AIzigTx#}7%tl8lpr)F0QY`;>0p1XD;Jcnw=b;a;n z>D8bJR{p4%ZUP0js)}zs8(qbIE@9hNEtyrW=X2%+GnojDMLuF9o3@VV+bjKvf~zktj@_iS=SB~bRBF0 zrKiUHMM$g0c1pO~Dd1f76}Qjf4oI@sj#mz3_T69q^|$}}hp&I`+xvgi$$(5G8^3y8 z!w^+CT3d?smXv+*-Q8kZH_X7S-;F8jq%ATeMLTPEn zw|A@SAS`mo6qNdB57+AN+TIkfDh#k?P!-hen7JMNl!jJm1HjH8YYfwDQQBUr>i?r)SXUk*OKm@tWmJv|8O_=}pEo8g+9?fWb*((o%RI4MXw26}OdBi?Dgxadpw%N(B8%w$N2kG4~e&9MLt|KTrR{<9yy z{-xi2_wRrI`X@IS6R8EKO7r$*(5Sta|BtDAdA)Ys(!2hi*RHB0rc8MtqF~3CU?UCF zwJG)uhz%Qppa}jMVoz+?P)hA+p%B4_5d)EgkOHL;f`tu3LNzO7XYXD6wAOl0em~cE zeyj5Q-gnODx$kk0agA$?`|up*oOA!|Uv5x3w}S;dXlF5da#cETAt|CDa3%P{>9(Xs z9UA*psGp1;S%W!i+T>V9Z$mggtDbyk0fRa=(aw6!Ytf67Nq)hTcsmI%XqF-?eo(Kg zBMERxoViT-RVapC8DV%nGwhF>2P0LrNS=+srJOfi$M#J>NUeDSB4M|>y`!_b8nshz z)u^A*)Gv-eCxo5T)k;xTr5)Ch_u}Ru9vbi)iK?c$B7+DAs)$|br2O#o z&fh^b&`oKtp}&<3q_vZ74f2KptfV!~%G3UWMQG!Z4zy9)?ua;^T?(nVrq)cY+nk#< z9~oaN_Khh{#KWIdZvh`W>rQa*-@rCz*CH9~Cj(GmqSe>fv^-~mPDEQM%fS7u64u%CY7Q4{wiWwP;`<1K9b%)O?NfzqHo zRvkf?sBe7v>QhgSg?J(F+QI#7Pv!+^3x?%VZ5V^IvB(Y9snV)cc8ZvylwhOx*Pr`M zyd*M?ig=TdS>j*-tku?C*6NnUb3Z7WCp#Wor2H#|dJq8kmU(aAZ#66!=FuBQ*+|BDlrYa| zP&%By9j6E>1TyW(nV^`El(M$Z@7|7w4qV6tUel**!z^?nP!5Dt;tgm!9nv%27+FPW zQJYsSyCs;IUtc_6vP;JtEGo@$2PdC{H2T!DxIlxDQ8~><3wjpg!qP5rSrI;lNOnpf zj_FP;v#Jx09`LOJ z)3%0z%9K~*05ry1SdlJ}_OoDu>YWZ%I;^x<*$_8w7miSk1>?j;;&l zOOzo&o{zBTc6iD`@>V2Lfej4r4;7;In#g zO(LPo8Yj3J0j#Y<$ZEX=INA+s+v)@dk3e4!@AAR@g7gM^WxtoLdpLZpH1D@+&=m*$)8&g>yz79ExVEuKk zi3NeCwx_}56E*v2`uI9lTJj;*e1E@w_fwQsSi!OD8F*kFha#gywcdrH?T#e@I5oRUV z;GV+81F}Y$SgjKX+^h_-y`ipb|6-Q_wiut9Mb!X28%FQYEO4?nfTA`dg}0^PmccdJ zFGdYF{y94hO*f~Z?$BD|u56MBTT_6?r5D^6HeW9L-T4e`8@oM>SH9+SB!2RQm2yO{ zhm?y!OvIqfjnsQEZ_R`4fwT}2TPrRRll$d}u`s79%dup3IN6G8mma61e4-?IFJl4u z_56Ccfb1%xyDMb+zklB12g2o9T~3r!(PnrP^}JIsgzGI?6q~*)qA^5!`lk?eu7Ytg ze=F9~Vi)epeDEyU6lxfUTS|~eMRPX~VHms)WFwGD@%g7UDes&-_e-Y!Oj?uxl58sM zMs=$+G!^aj@*CWCf>Dfj7DN5{Fgg~=9DmcSOl4L5yYPVr+N_vq{a|{|SVBca(lB(@ z+*hz}*oH`(dFsr>9gwt?5xxVX;^yq}%!k^Z!KFVAiy)9W(et{WzV@M0w!~A9O-bQ1 zg@o!}g``&1mW{opG5KUAM#})rOu>v299x#!ZW$HpVTGfMhMuMq_o2#eb+eJLOAuq?H|WQ0wc)*;ydQ=At#~5Mi(!K0rjXGuP-w1?2uvwo%?=|KJqt{p z?RH-#(za+U!jIm=Ig_!S(ej1^^FGTl7X@7gLZ-ynNfieeKOn{`j&w#kmPAiV#Kc;w zE`F)Gj!esl`c@FrEqRva%QqE_Y_{?w>v1;ThY$@@?mi(zul`v)4jOgn$ zyHON5tAw?F)u*QGm|5%ga`yVvc|W+q_c3Fg(E-c&iLTIMedk8`7&!ohhm{moEzm6> z4MB-!rT2AJvv{(Dj8h#dz0*+hlrSdKHqA(tgm@IkMe%I;m|WKkdi)`E4{Q)dr0a1s zp;XV;D;oow;@ub6!_O_kN<8=8^U8|B;>PH)X0GUMBne0Pvf)gKnasb|y!)!5%G2QV z+vN{mA}yWVf&cX8|NH6Z-~I00|J7jHPg?quE*-0_ZHhFViHf+~VbtodBy^B2`{H>!x&Fqqx#sNh*JzQi+abKZ{ENAjc?vB_EU(0Fgi(HIEItGJgGC}i4x{u=+6`NmkGk5 zTtyBVlnjK>Uen&}iJGwBmfmpypyW%U7*U18jt;grYfdfOR=no2@ziNTrRoPpB^_U? z!mMtg8G3FET&1~-@I;=_(D|b;Z+`OQ&%gWW^-uoXyMO!D=RaR#fV4SXEY9A1cs$BN z^)fRQx%EJ1y4}w zUF0_?Ac3x6_0n5M%XR9*V<D?42OzmVHdA+K`1f)D_NOsiX0{Yl zk;-Z;{SQt$0{7} zy!)5J#_i{1J!|^}@0)i%FOj26Z@f$Wu%@Z$%;`;BhLdjsmT)6V%Xd+%XiQtq)-@A> zz6y>xV=)?<2O)25JER3B&7Mw6T8pYW$iK}NPBQkpSO(0Ol?|QIi$7c8=tma<92g&c zo~u>q*lZt=n*bi;xpSF!tlrY?Jb=5V^RtUrL;u*J@J%O~KBAUmk-I^z`C_yGR#zJc zD>n1xwLY=agABG`8`x97pPo2`Wmr-b0}~%sQOkKIq_kuyj(S%A3#?fx9W4Xp!5Db^ zy3fFL2iYfMHyg5CBoWkFv#PIO-uEj4BRS+qDV#kP-$>+^Q3f(AFM1JzohS36nn1fA z(bY0+J9!qvCjuo*TU9RJMIB$K9*vrXg{qL)(nBz6IeWFF_8=({zZ>S{Eu14cB_P8X*QCs zU#BcZ0PCUs#Xi8*cJZH9!i6+Bzh&Ra%q{g;Az!#~JFYTU*G?0Jh8j_p-cIv8voLz8 z^vvF)JQpQ15{;vHj}|P2HFG*YO$#D}c~JsWl?tZk14|Uk|4G$`j?BxZk0ofs)Ipsj zY(_!@XfDr-Q25?m#>YznQs?#SKG!XE8R^|;EFpY)gouP=$M>pvy~=IX5t4jxOcp82 zXjIOn#Nu}8z?WP!U}T8X`IyBQG8Xs_%B(zR0C<-G^3h1bBtSTcANa${Vn|R0NvfOm zd|OphxSohBo&4d7uGDudZUVr2I(u)=iD2lvEW3+nirZ$+6^gzCbZ!J&P^RA{kYICs zTgq6+S2dyOL!*A$C_P;-i`-q7`FdL$XiDOF;Tf zpej`5dHPqeXbD!4s{D%Uem85>iut48;;-1aaQVVhY?d38`*^x|g}AAN>Ry1knDPdv zgoiJa>%|+K*FAz2;wBqfW`kdusv^KeY8^i_DJ>R({Io|f4Ex&F$+_WN_#CW(iIB3? zbZsL$mQR&pqa{E&7BXNH?F&><7F&50hN>XpW3^W`Rr#=yKbr|wM1KwYcJC?THT`W< zWw)AyHLOThhf7}wO4Z?^#ukZo3MKPQ54}^>;Oj87p{7@X<3R1j9fl5?9ID|a`vd{m ztK`t0f!;)8){QV!+A^hb6?s%h|C9$=QeAEosC2eV-N-S3#7p-of!Rcc?GgYKJ#BO~ zbk3AmRT2UImDW0RV6r#!Lf4Lf^xocHv^6DhbcT8DsDC39ez~H>S@1-t1e#P@YG(#v zi)(>OF7b6J`i*augrgeQCiz3IQ@%VEvD$7~!1h>Ujn0{60RSb+l|HZLDY`>3Eul}L zO)yy+8~0Gv)R05d`&Vy2{p8Dk|LOA||Mt88==JA+?$zr)j&z}~^`vGMAZh$SnW;Ku z+-AaOmkcTp%ENOGds2$?hzgSZjzX76`iA+gr>u|6>yuim)oa4pUM_cY#xi?^ar!Js z6RWZu&*@QkTsaxO%$qRF;i(#~PU@@HfwYz;1DbFYF5jR0IEya7G5j)xr zt;Pv(JVFy1%vkDRi_e=A@itC$0LbqD*Oueqi`IQE1)eH~x#nX8FC$2=ZYZ(NSd+8l zwP7t0x3&cH9N5m2C}eVp(dCGd_|1n8n^UuvrP$1jQv$Zr0d2*WC{} z(u-z%-n&e&w}wGgHFDQZxH`5H3a@}Fi#adFAqN5UXLx&p*~atx|y_{I!7Z*^Gxt3)XZ1V#B82Mc%F09>b&qpaz1v6k<}TwK+1u(v&ZoJg6_P$ zKp?LkSK3q&EjdmQjw{K*Yr}Cw8ewyje;WkK z7I`9(Q`XImmd+o$^^wp$mc`vx=Z|fEv~R0@YQSFK+bDfKpZdUruU?!WQyg5>2X`^1 zs_JEI8JWD5dNUBBC{Lwxr^qQHYb_Bk17=nbGEbG2eEarPU6}894@!=Uq0C&C zR)VP@)ux;)wHvrbT6QaO%}EYus(5d`bX2A}ZIE3W+mY+s>Abr2v&=!e<#ipTnhrx+ z8mCBdu21NegNJpBY9@O+`Cs~ERXx)i5Zu4YjOg8kAb+sSj>BMV{Jfr6n!!@-8O7^%yDBR81rc7rxypb_un?x44qjgmd+Yr4`77#6*7P8AAkKX zUcLQsXH`p5^x`#GHAq!vM#O2wh_)1keIhkDv{@Q4wJw5gdarzeNms5}%4^HxHr@xa z_j#Z0Sgp5om?R?Ivyd1q^mm&}cL+c010*cTLQDUnQ?j0ud0p7%#-@{m`Zb20Vui=Q zD)B86=v?WF`Shri$AzPYd@iJBy|Pl2O31#Cp+RO-RBiNvJW6lK=9k6+&W$uu(2?j? z?v2hHyKZnBw<=YUEF=qHc|rpp`y04+_#H{+IA1!JFfKdxe%@6WscZ2x>mcy8b|oYL zb)`_Jp>{0yqFPd_f<|H#9ivNQt3Guyc`3V8_?VXWU9cK|vw@1r1zK1PXHhL?QIdz} z0v#~sk@+`0bSg3wEgv64(Yw!;kp}FXJXCW&U}>)$J@wv&k7gS6pJnC)FfHcA@4w%e z2tN|`zqeRQPklHK!m`ek3ZHq0{_qJ4)r-%(Wh-b8jU8k$moVGHCC7(dlKZF*%gJy$ zFVabu&<8*cK`oTkTi;eGX9@`O`8g6Rg+d2w6r1^)4Axcgm&SU4$nFqMC1RRrsw?i! za2DF>@Y>JU2FbU-eDya9@1KABAHMnW&$bF_KkJR8Q(XgnbAL1qGo9SYB3 zv(2p>!GZToBLY^(w@as*{ts5K%@chKOHbcyysF7La9(hU#0F|Dj{8}m==41E9bnR% zy_r#e-ML7W0W0O#y2@%IO}R*DyAECZmr3lZy1G!KhU@GiGa$}i+R>bcXEgFR2HN8)`}JonZjFWB&PDHG8_N{=x45>maAvoHEmxK# zf|Xy1+ESITo5fod_|5BYFdV!b^rJLgo6I8nfNR zG8V06q>mtO>UjNbrym}#$}-8CM^AzK;-e!SE{~RQW}-y;v6IGVS99SH7ADY{y@!Q> z>jhTWsP12TwM}U|*b*=w_O@1Da1=n5w)Mg12f@}q7&!-P$Yx(Nqb;4&U(Y9z1-*;* znT#aiWte>kmN^4lLv^meb=9Kt(nN9R;kUvNnii&gmF2o$e!!+jULVGcQ7JgN(>=_{ z(9n%snIcBEI1;qH-(h?gl8=21(O2H83mGmOq`hpF*9K@>Y zU)8*Q^WoKZZ~n$lKmV6s-u?0GFTYiDDIm5SF$I)j@srDqOcf$v8Mx3F-JEx}TP4pV zQ|AdA#FjL};hO2Par#37PZ2CTcJ6oUt-vdY|M;xy3MkltS(JOh?2=6o#`{F%MvwfN z+T`Jshicid*Gj7C1EH-V3_$ngn=6&1%aIr5%q+lJoweGAc1(Edh%c@smX02=5Px`@ z*oI$#YZ-I9AgaxX;$U*BGuD6$b|9_CQeWE&ai9BJUbD>B<`O!}(i-M@7YH^m#ySR? zxRdIi8C--;-wHDl6*Uo*qW-9`uMOAGt9gSemWm|mYPgQH4!wdy)fqs_qR}*g-tgBp zv!NB)G_0Ja_1$zqAA&fU{}}+heX(-`ZN#!WK*_tFBEu8t|D|x~9Fk?CE4-$9r$>^r z?PmYvOF7w85wue=&ZW(ZY0kqh4&>^Vl1qBG=)?4*V%=a^wcut5kQ8kDjdjW^SZv{` zqQ2%3%b-P%Kv-@({SkYzeIQjLH`Fp78$Qd`vi=$Bvr=I779{0r~?lh>dB;_JGV z>-fnJ6h7N|`Kw|Y8@}cpA`ak82X&L?;6@7XYf5HBOwUuw;r{ZJ-8q{^a&f1Q6!)u2*tJmC~_{fBQU9>zM^x@`reud{?YL!g54`P28s ztF_FOhZK3Wbzj@p9=znO0YYAnkvyzX2OGRb3b?NDs>oczo?J)PUYLX@y`MjReEZ=; zSKXsH`&f+;nTTAy^!8+@?kG#nsnsT99*vX_4L9OcPc(?&mJ#d6PoLg>utH5k=Z(4P zs|(T{M>6XoJ$tEGOiRA3L6RPrjX>fxy)_$p;lOyqdU0=Le6mz2a!tR~!GZs>!P8LK zb^>WjZ5hYtnAd6WlK)%3XV?~pjbKpqAdO`sQx zGQapYN{uOsv)ZjFT4H^7`QG=-W;!Po7Cm*WA>2)+kz>Tmi)#dDiWaEcDN(c8^b_`V zA$>p`ol68L#T_)tn1uyeOy{c~G3#8gFLW6>PMG0u%o0sWMeKS@`s?oPkH*+vPGv;) z^%_UDbUqNAWy=!#6r54P9+sL`K9G4K+yP|B&~QOoOrtJ3T4Z~QX@HP--J~H52mJx) zG3v~AmX({LHd^Eg9w(N-j zB~mc%vD^8zB$HNkNIktiSYH4&Eq#4acc9ztB+9o;dPj`KE}my>5Y80oDo&Tp&1_%E>vYP(q3pItM09c1ZIZ8RJ7i19op&{)3ZhuUQ} zVdE#7y_h|W<~0f8f)JN`FrnjW4+x$9WzXjhbYuqRPPRvEm~R-ICm0{ZPz_tz4`F^+c$sxPrm-{=Xd|` z>z8jx&$)m^&~z3<-JIsf!qmvVK{?xLOkCTR3|9PvIt&3?;y(q-i{|kZtuZ3w@K;sZ z_kCt0X-OV!67WH8bXdK7nQps{JKLs=4jWe81i4?lIFc@9PNeKxDtN0|5 zbx;L&^q@#)s!^rUT0a9_MZ93{7@kz)g8}?hkl+%PAQ*D(!+=PChi6=jw5i3<1lRb6 zBMr&{upS-fWcq?I$md66w$TrpFS5mtrLzZ12M1&Oqn*~VaYZ03LpPlXa@)M&+qCaA zZ@upzC6Eqkdd*&W<=lQ|aare;z=Kt@Cz-Wc=WWM`(_1B9U;oChU;n{RKK)m}{q8?_ z{pFvpW2W(RFT+xHPS)r`CW>umIF0jpsurev6u|jN48oTUPulSS(`DGyRV*@8E&V<$y^amtS)t2%ziK!c-dMtWo>tYuBb|1zE{_N z#ApjjVup14X%EB*gFmzlTkc_Q{nxX3`aWjzqB^AQ zPHCY*4};)Nc_#*UF65#(h-yNa^HS~y{ku62(5MEPBkbc_X}U`nDnK%g#4Dr`Q9!K7X5+3i`|mSn@BBrSNi|=Pljp zmg^|ye(R@Qg)0ZP?k^K^8cmN!Rr}TujwB<#n0&mm3eJ3kNEt06=w8;x=k*B<0#iQt zS&sh7qAZA9uj%TZSg(SjbxedrhvJDM&E+UN?4x#SZp5>9B4;}L_*H$`+ zqzP`^dVjQ6GL$q2C80u0Y(v48>1WQE^Xb#~_02c$KH!U|@YiK@zcIPfu4-CV&9ADc zPg|j>k!b>rh%z+QuJ`KO#Kbv6BVPJqiR1gdAj2j-$pnqJAVHj}(5i6gP2#6c3d05$ zLEOFX8azX&wa`@a&Oi%hIB)&MZ( zHsp?jxsWz9T84}m28T@OKhe@I*a7Wb?do61ACqb_MsB_)!jY1@@>uI&YRiQRmei%K zwA>|trb_(F1Nc*+1(!9Ge3jd@joyQ?f~*AHEMis|jhM`z@;2xm!b~uclMt!JjvMoB zwVn3eG6sP`?muY5U>)zL13Ievi23XfOhM;SI>+z8XLKZBzmZG3s9%gK>2H03H)>#( zJm<&>VC(5q8qC3Lmu6P0dchOxYUx>ykMf}4D$Ovg(o0(f=F{3Iuw=&N>0VHi$yV8Q z`AHAa-YY3O)n->PS;A^n;jfgBQ5NHG-hA`zm%sBTUtag@=$B9bi~-|ud`P3*|!Gn5A|msF00#`D_g`?IlxS@ zWk@mnWSywC{@T-(ez2OZAzTfd(0c44`ZceC1oVEADG&={7CQF#MzooxA*F#@3Baj4 z6^8r1jY98ADQ);#UX-4#t5zH;V||GsQYh~c3^$9f>jKagTZ4qtGXY{G#$pAG*PwT@ z)G1UdlD8ajgH>QtvCgkDrOCN}qIeqyYx<;CY8S`}8%>a?`iU-5UQ9{sTl-1()Fq$4 zpK}MmwUYmM*|4r*3U9W)CXjC&n9>rs_eysDJa`M%1-Fn^d;D&gOL5(PYttHKgm&d9y=BX3TxjI@`g04CAC#95Dh?1%=GiV8noLiX{_bE34ErU zcteu2;5D$H&MZn}=eqcC9lPc=*%kxBW53h;MiwL=s+2Uph*nFcma1@g?h9m?e zaJR%~peOwp7V|K-xski>T1Ro)YC~Z-g@@E6+ZWbZlT0t5CSpHe809mR3i$jI&`CqT})|S;Au?Dceq!8yd zX~g(}Tb|0Fq*htlT5Y#KDwb`Tl^3l>3!lDIu(irS6{oWs8WvYI3P45s>zUh5Ga~

sLQ|>3U&V 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

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