Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Changelog

## 0.13.0 - 2026-08-22

- Add a pinned, read-only Beeper Desktop provider for contacts and messaging
projections across locally connected accounts.
- Add `wrench beeper export-message-like-me` for private,
provenance-preserving Message Like Me bundles with canonical digests,
explicit completeness, graph validation, and no media downloads.
- Export Beeper accounts sequentially through the pinned official CLI with
redacted account-level progress, elapsed-time heartbeats, and cumulative
chat and message counts.
- Add durable process-aware recovery, global export admission, monitored raw
working limits, and atomic validated Message Like Me bundle publication.
- Normalize account-local self aliases before record allocation, preserve
distinct provider reaction facts, and reject contradictory identity or
snapshot evidence without exposing private coordinates. Bound repeated
participant work independently from output record cardinality.
146 changes: 143 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@ The skill teaches Codex, Claude Code, Cursor, and other compatible coding
agents when to use Wrench, how to preserve its trust boundaries, and how to
install the CLI if it is missing. Start a new agent session after installation.

Install the current immutable CLI release from the `v0.12.1` tag:
Install the current immutable CLI release from the `v0.13.0` tag:

```sh
bun add --global github:hraness/wrench#v0.12.1
bun add --global github:hraness/wrench#v0.13.0
wrench adapter sync-bundled --json
wrench doctor
```
Expand All @@ -102,7 +102,7 @@ Install Wrench in an agent or application that owns its own model, planning,
tool loop, approvals, and interface:

```sh
bun add github:hraness/wrench#v0.12.1
bun add github:hraness/wrench#v0.13.0
```

```ts
Expand Down Expand Up @@ -303,6 +303,7 @@ not turn missing message history into zero activity.
| Provider | Contact collection | Directional statistics |
| --- | --- | --- |
| Gmail | Google People connections | Bounded Gmail message scans with explicit truncation |
| Beeper local Desktop | One bounded account-aware page from the already-authorized local Desktop projection | Unavailable; Wrench does not scan message history while listing contacts |
| LinkedIn official API | First-degree connections with locale-selection evidence | Unavailable; the Connections API does not expose ordinary inbox history |
| Instagram authenticated web | Unique non-viewer participants from the reviewed first Direct inbox summary page, with explicit first-page and pagination incompleteness | Unavailable until acknowledgement-free message-history paging is reviewed |
| WhatsApp linked device | One page of the authenticated account owner's private, quiescent Whatsmeow contact store | Unavailable; Wrench does not treat a linked-device message cache as account-owned history |
Expand Down Expand Up @@ -340,6 +341,145 @@ Wrench will not install or expose this surface until it can bind the TDLib
authorization lifecycle, account identity, local database, paging behavior,
and message-history completeness without weakening the linked-device boundary.

### Beeper local read-only projection

The bundled `beeper-linked-device` source plugin reads an existing Beeper
Desktop authorization through the official Beeper CLI 0.6.2. Wrench accepts
only the exact pinned macOS arm64 binary, the fixed selected `desktop` target,
and three JSON operations: `contacts.list`, `messaging.list`, and
`messaging.read`. Every child command uses Beeper's read-only mode. The plugin
does not expose raw API calls, targets, media downloads, sends, presence,
pairing, sync, or other CLI commands.

Install the official CLI and authorize it to the local Desktop app first:

```sh
brew install beeper/tap/cli
beeper setup
wrench adapter sync-bundled --json
wrench auth add beeper-main --linked-device beeper \
--device-store "${HOME}/.beeper"
wrench auth bind beeper-main --site beeper
```

The export integrity pin is the official macOS arm64 CLI 0.6.2, not the
moving Homebrew formula name. The command above is sufficient only while
`beeper version` reports 0.6.2. If the tap has advanced, use the 0.6.2 asset
from the [official CLI releases](https://github.com/beeper/cli/releases)
asset and install its `beeper` executable at
`<WRENCH_STATE_HOME>/tools/beeper/0.6.2/beeper` (the default state home is
`~/.local/share/wrench`). The reviewed release archive has SHA-256
`688ccde7e7d044d33980cd06474bf1ae7215ccf8ca79967262fa3bfb85a2589a`.
After installation, Wrench enforces executable SHA-256
`48aa895449129c793a212ea19f69a534adc34a8adc4037ca1d7da9e648716425`
and rejects every other executable byte sequence before reading private data.

Binding hashes the stable local self-account coordinate before storing or
printing it. The first bind or read may take longer while the pinned CLI unpacks
its embedded payload into an operation-private cache. Read the local account
and conversation identifiers, then request one exact conversation page:

```sh
wrench beeper-local messaging.list --auth beeper-main \
--input '{"limit":100}' --json
wrench beeper-local messaging.read --auth beeper-main \
--input '{"account_id":"<account-id>","conversation_id":"<chat-id>","limit":100}' --json
```

Create a private, agent-ready Message Like Me bundle from every connected
account materialized by Beeper Desktop:

```sh
wrench beeper export-message-like-me --auth beeper-main \
--output /absolute/path/to/new-message-like-me-bundle --json
```

The command uses the pinned official CLI directly. It enumerates the connected
account realm, then runs the official `export --no-attachments` command once per
account in a deterministic order. Each invocation selects its account through
an operation-private CLI config, so account identifiers never appear in command
arguments, environment paths, or progress output. Stderr reports the account
ordinal and cumulative validated chat and message counts. Long account,
conversion, bundle-validation, and publication phases repeat their elapsed time
every 30 seconds, including final private-shard cleanup. It prints the private
recovery check before that work begins, so stale cleanup is visible too. A final
account enumeration rejects a realm that changed while the sequential snapshot
was running.

Wrench retains each validated raw account shard until the complete sanitized
bundle passes its graph and digest checks. It builds all six NDJSON artifacts
and `manifest.json` in a private sibling directory, fsyncs them, and exposes the
seven-file bundle with one atomic directory rename. The requested output path
stays absent until that commit. Success removes the raw shards; failure or
cancellation removes owned staging and leaves no partial output. The output
directory is mode 0700, and every file is mode 0600 with a canonical SHA-256
digest.

Each connected account has exactly one normalized self participant, anchored by
the account user's stable Beeper ID. Before emitting records, Wrench proves a
deterministic candidate chat prefix against the record, byte, and participant
work bounds, then derives only hashed identity evidence from that prefix. If
normalization changes the admitted prefix, Wrench discards the provisional
state and repeats with the shorter prefix. Explicit chat `isSelf` values and
message `isSender` values establish account-local self and peer evidence. Later
admitted evidence applies to earlier chats, a rejected suffix cannot affect the
retained facts, message files stay bound to their validated SHA-256 digests, and
contradictory retained evidence stops the export without publishing. Reactions
inherit a normalized participant reference while their raw provider tuple
remains only inside a composite hash. Nonunique provider reaction IDs are
preserved with the categorical `reaction-provider-id-non-unique` warning.

The JSON result reports the manifest path and digest, record counts,
completeness, and warnings. `--limit-chats` is global across the account
sequence. `--limit-messages` and `--max-participants` apply to each chat, which
matches the official CLI flags. Reached limits are recorded as truncation.
Wrench always passes hard ceilings of 100,000 chats and 1,000,000 messages per
chat, and it emits a coherent truncated bundle before the 500,000-record or 512
MiB bundle ceiling. Conversion also stops at a deterministic chat boundary
before 250,000 participant occurrences across account anchors, rosters, message
senders, reaction actors, and implied self insertions for direct chats. This
bounds normalization work even when many chats repeat the same participants.
One chat JSON file is limited to 64 MiB so foreign input cannot force a
multi-gigabyte allocation; an oversized chat is omitted with explicit truncated
completeness and a warning. While the official CLI is
running, Wrench monitors the complete private working tree against a 4 GiB
ceiling every 500 ms and independently checks that at least 2 GiB remains free
on the filesystem. This is a monitored safety ceiling, not an operating-system
quota. After each account validates, Wrench immediately removes the redundant
Markdown and HTML renderings while retaining the hash-bound JSON needed for the
final conversion. Cleanup first moves each owned directory into a private
quarantine and verifies its filesystem identity before recursive removal.

Before credentials or message bytes enter a raw working directory, Wrench
wins one atomic export-admission claim shared across all Beeper auth IDs. A
second invocation stops before account discovery while a live or
uninspectable owner holds that claim. A later invocation can reclaim it only
after proving that the exact owner is no longer running.

After admission, Wrench writes a durable private lease containing the directory
and process identities.
The atomic bundle stage receives the same protection. A later invocation
reclaims a stale directory only after proving that its exact owner, and any
recorded Beeper child, is no longer running. Live or indeterminate owners are
left untouched and the command stops with a categorical error. If a crash
lands between the atomic rename and lease release, recovery recognizes the
same directory at the requested output path and preserves the published
bundle.

The [Beeper Desktop API MCP project](https://github.com/beeper/desktop-api-mcp)
is intended to expose Beeper tools to an MCP client. This export path uses the
official CLI because Wrench needs a pinned, bounded, read-only file snapshot
that it can validate and publish atomically.

Contact and chat lists are bounded to 200 records because CLI 0.6.2 exposes no
continuation cursor for those commands. Message pages derive the next
before/after cursor only from the terminal returned message ID and reject
duplicates or a non-advancing cursor at normalization. Output marks remote
history coverage unknown, preserves account/network/reply/edit/delete and
reaction provenance, and includes attachment metadata without media IDs,
paths, URLs, or downloads. This is a local materialized view, not a claim that
every connected network has finished backfilling its remote history.

### Gmail

Gmail uses the official Gmail and People APIs. Download one Google OAuth
Expand Down
7 changes: 7 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ recovery, receipts, bounds, redaction, activation, and lifecycle serialization.
A plugin owns exact provider identity, route and operation descriptors, request
and response contracts, account probes, execution, and reconciliation logic.

A linked-device binding may either own an explicit `inspect`/`pair`/`syncOnce`
lifecycle or attach read-only to an independently managed local source. The
latter must omit the lifecycle declaration and all mutating surfaces. Its auth
locator is established with `wrench auth add ... --linked-device ...
--device-store ...`, then account-bound with `wrench auth bind`; Wrench must not
suggest pairing or syncing a lifecycle the plugin does not declare.

## Start inert

Create a portable package with one `capture-required` reservation:
Expand Down
13 changes: 12 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hraness/wrench",
"version": "0.12.1",
"version": "0.13.0",
"description": "Open-source CLI and TypeScript SDK for precise web capabilities for AI agents: page capture, verified media archives, encrypted reads, and typed provider operations.",
"license": "MIT",
"type": "module",
Expand Down Expand Up @@ -64,6 +64,7 @@
"src/article-draft-embeds.ts",
"src/article-draft-images.ts",
"src/assets/adapter-template/wrench-adapter.json",
"src/assets/adapters/beeper/wrench-web-adapter.json",
"src/assets/adapters/bluesky/wrench-web-adapter.json",
"src/assets/adapters/bluesky/wrench-web-adapter.v1.0.0.json",
"src/assets/adapters/bluesky/wrench-web-adapter.v1.1.0.json",
Expand Down Expand Up @@ -158,6 +159,11 @@
"src/assets/code-owned-provider-template/runtime.ts.template",
"src/assets/code-owned-provider-template/wrench-adapter.json.template",
"src/auth.ts",
"src/beeper-message-like-me-cli.ts",
"src/beeper-message-like-me-export.ts",
"src/beeper-message-like-me-recovery.ts",
"src/beeper-message-like-me-source.ts",
"src/fixtures/beeper-message-like-me-v1",
"src/browser-admission.ts",
"src/browser-snapshots.ts",
"src/browser.ts",
Expand Down Expand Up @@ -215,6 +221,7 @@
"src/pinned-https.ts",
"src/plan-assets.ts",
"src/platform-catalog.ts",
"src/plugins/beeper-linked-device/plugin.ts",
"src/plugins/bluesky-web/plugin.ts",
"src/plugins/gmail-official/plugin.ts",
"src/plugins/hacker-news-web/plugin.ts",
Expand Down Expand Up @@ -268,6 +275,9 @@
"src/read-projection-admission.ts",
"src/read-client.ts",
"src/read-projections.ts",
"src/providers/beeper-local-runtime.ts",
"src/providers/beeper-local.ts",
"src/providers/beeper-omni.ts",
"src/providers/bluesky-web-runtime.ts",
"src/providers/bluesky-web.ts",
"src/providers/iso-bmff.ts",
Expand Down Expand Up @@ -345,6 +355,7 @@
"src/wrench.ts",
"skills",
"README.md",
"CHANGELOG.md",
"LICENSE"
],
"scripts": {
Expand Down
23 changes: 23 additions & 0 deletions scripts/generate-beeper-message-like-me-golden.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { mkdir } from "node:fs/promises";
import { resolve } from "node:path";

import { exportBeeperMessageLikeMeBundle } from "../src/beeper-message-like-me-export";
import {
BEEPER_MESSAGE_LIKE_ME_GOLDEN_FINISHED_AT,
BEEPER_MESSAGE_LIKE_ME_GOLDEN_STARTED_AT,
createBeeperMessageLikeMeGoldenSource,
} from "../src/beeper-message-like-me-golden-fixture";

const parent = resolve(import.meta.dir, "..", "src", "fixtures");
const outputRoot = resolve(parent, "beeper-message-like-me-v1");
await mkdir(parent, { recursive: true, mode: 0o755 });
const instants = [
BEEPER_MESSAGE_LIKE_ME_GOLDEN_STARTED_AT,
BEEPER_MESSAGE_LIKE_ME_GOLDEN_FINISHED_AT,
];
const result = await exportBeeperMessageLikeMeBundle({
outputRoot,
source: createBeeperMessageLikeMeGoldenSource(),
clock: () => new Date(instants.shift() ?? "invalid"),
});
process.stdout.write(`${result.manifestSha256}\n`);
45 changes: 45 additions & 0 deletions scripts/package-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,27 @@ async function run(command: string[], cwd: string): Promise<void> {
if (exitCode !== 0) throw new Error(`Command failed (${String(exitCode)}): ${command.join(" ")}`);
}

async function runExpectingFailure(
command: string[],
cwd: string,
expectedExitCode: number,
expectedDiagnostic: string,
): Promise<void> {
const child = Bun.spawn(command, { cwd, stdout: "pipe", stderr: "pipe" });
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
]);
if (
exitCode !== expectedExitCode
|| stdout.length !== 0
|| !stderr.includes(expectedDiagnostic)
) {
throw new Error(`Installed CLI failure contract drifted for: ${command.join(" ")}`);
}
}

async function collectMarkdownFiles(root: string): Promise<readonly string[]> {
const entries = await readdir(root, { withFileTypes: true });
const files: string[] = [];
Expand Down Expand Up @@ -115,6 +136,30 @@ try {
for (const binName of binNames) {
await run([join(consumer, "node_modules", ".bin", binName), "--help"], consumer);
}
await access(join(
consumer,
"node_modules",
"@hraness",
"wrench",
"src",
"fixtures",
"beeper-message-like-me-v1",
"manifest.json",
));
await run([
process.execPath,
"-e",
"await import('./node_modules/@hraness/wrench/src/beeper-message-like-me-cli.ts')",
], consumer);
await runExpectingFailure([
join(consumer, "node_modules", ".bin", "wrench"),
"beeper",
"export-message-like-me",
"--auth",
"beeper-main",
"--output",
"relative",
], consumer, 2, "normalized-absolute-directory");
if (verificationPackages.length > 0) {
await run([process.execPath, "add", ...verificationPackages, "--ignore-scripts"], consumer);
}
Expand Down
4 changes: 2 additions & 2 deletions skills/wrench/references/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ When the user asked to install or use Wrench, install the current immutable
release and its reviewed bundled adapter manifests:

```sh
bun add --global github:hraness/wrench#v0.12.1
bun add --global github:hraness/wrench#v0.13.0
wrench adapter sync-bundled --json
wrench --help
wrench doctor --json
Expand All @@ -31,7 +31,7 @@ Do not clone the repository merely to run the CLI. Importing the SDK is a
separate project dependency and does not install a global command:

```sh
bun add github:hraness/wrench#v0.12.1
bun add github:hraness/wrench#v0.13.0
```

## Add only required optional tools
Expand Down
Loading