From 2bcb05e373ca2ed62b86f14747c8eba59e4a9c47 Mon Sep 17 00:00:00 2001 From: OceanLi <122793010+ohdearquant@users.noreply.github.com> Date: Fri, 22 May 2026 12:27:49 -0400 Subject: [PATCH] feat: scaffold channel surface + communication pack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ChannelHandler trait, envelope types, and three reference impl scaffolds (Gmail, macOS Mail, macOS iMessage). Plus khive-pack-comm declaring note_kind=message for the communication domain. Doc-comment-only scaffolds — impl is next step. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/Cargo.toml | 5 + crates/khive-channel-gmail/Cargo.toml | 22 +++ crates/khive-channel-gmail/src/lib.rs | 13 ++ .../khive-channel-macos-imessage/Cargo.toml | 22 +++ .../khive-channel-macos-imessage/src/lib.rs | 15 ++ crates/khive-channel-macos-mail/Cargo.toml | 22 +++ .../docs/reference/mail_bridge.applescript | 184 ++++++++++++++++++ .../docs/reference/test_mail_sync.sh | 142 ++++++++++++++ crates/khive-channel-macos-mail/src/lib.rs | 15 ++ crates/khive-channel/Cargo.toml | 20 ++ crates/khive-channel/src/lib.rs | 10 + crates/khive-pack-comm/Cargo.toml | 24 +++ crates/khive-pack-comm/src/lib.rs | 20 ++ 13 files changed, 514 insertions(+) create mode 100644 crates/khive-channel-gmail/Cargo.toml create mode 100644 crates/khive-channel-gmail/src/lib.rs create mode 100644 crates/khive-channel-macos-imessage/Cargo.toml create mode 100644 crates/khive-channel-macos-imessage/src/lib.rs create mode 100644 crates/khive-channel-macos-mail/Cargo.toml create mode 100644 crates/khive-channel-macos-mail/docs/reference/mail_bridge.applescript create mode 100644 crates/khive-channel-macos-mail/docs/reference/test_mail_sync.sh create mode 100644 crates/khive-channel-macos-mail/src/lib.rs create mode 100644 crates/khive-channel/Cargo.toml create mode 100644 crates/khive-channel/src/lib.rs create mode 100644 crates/khive-pack-comm/Cargo.toml create mode 100644 crates/khive-pack-comm/src/lib.rs diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 289e4cc3..b9433dd2 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -15,6 +15,11 @@ members = [ "khive-pack-gtd", "khive-pack-memory", "khive-pack-brain", + "khive-pack-comm", + "khive-channel", + "khive-channel-gmail", + "khive-channel-macos-mail", + "khive-channel-macos-imessage", "khive-mcp", "khive-vcs", "kkernel", diff --git a/crates/khive-channel-gmail/Cargo.toml b/crates/khive-channel-gmail/Cargo.toml new file mode 100644 index 00000000..e0db6a9d --- /dev/null +++ b/crates/khive-channel-gmail/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "khive-channel-gmail" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +description = "Gmail channel handler — reference khive-channel implementation (OAuth + Gmail API)" + +[dependencies] +khive-types = { version = "0.2.0", path = "../khive-types", features = ["serde"] } +khive-channel = { version = "0.2.0", path = "../khive-channel" } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +chrono = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } diff --git a/crates/khive-channel-gmail/src/lib.rs b/crates/khive-channel-gmail/src/lib.rs new file mode 100644 index 00000000..d9776413 --- /dev/null +++ b/crates/khive-channel-gmail/src/lib.rs @@ -0,0 +1,13 @@ +//! khive-channel-gmail — Gmail reference implementation of [`khive_channel::ChannelHandler`]. +//! +//! Demonstrates: OAuth flow, send semantics, fetch/poll/webhook, edge-creation hooks. +//! Reference implementation for community to model future channels on +//! (Slack, Discord, SMS, Telegram, ...). +//! +//! **OSS-quality reference**, NOT the gated version Cloud sells. The Cloud +//! offering will use a separate, formal-gate-wrapped implementation with +//! compliance + audit guarantees on top of the same trait surface. +//! +//! Scaffold only — auth flow, send/fetch implementation, and webhook wiring +//! are deferred (see +//! `.khive/notes/strategy_20260522_open_core_split.md`). diff --git a/crates/khive-channel-macos-imessage/Cargo.toml b/crates/khive-channel-macos-imessage/Cargo.toml new file mode 100644 index 00000000..a2a87849 --- /dev/null +++ b/crates/khive-channel-macos-imessage/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "khive-channel-macos-imessage" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +description = "macOS Messages.app / iMessage channel handler — reference khive-channel implementation using AppleScript / Messages framework" + +[dependencies] +khive-types = { version = "0.2.0", path = "../khive-types", features = ["serde"] } +khive-channel = { version = "0.2.0", path = "../khive-channel" } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +chrono = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } diff --git a/crates/khive-channel-macos-imessage/src/lib.rs b/crates/khive-channel-macos-imessage/src/lib.rs new file mode 100644 index 00000000..0c8f81ad --- /dev/null +++ b/crates/khive-channel-macos-imessage/src/lib.rs @@ -0,0 +1,15 @@ +//! khive-channel-macos-imessage — macOS Messages.app / iMessage implementation of [`khive_channel::ChannelHandler`]. +//! +//! Local-first messaging. Uses the user's existing Messages.app + iMessage +//! credentials, accesses the local `chat.db`, and sends via AppleScript or +//! the Messages framework. +//! +//! Demonstrates: SMS + iMessage send paths, conversation thread mapping to +//! khive entities (contacts), local-only operation (no third-party servers). +//! +//! Scaffold only — chat.db read path, AppleScript send bridge, and +//! delivery-receipt handling are deferred (see +//! `.khive/notes/strategy_20260522_open_core_split.md`). + +// TODO(post-MVP): gate macOS-only build with `#[cfg(target_os = "macos")]` +// once the implementation lands, so this crate is buildable on non-Mac CI. diff --git a/crates/khive-channel-macos-mail/Cargo.toml b/crates/khive-channel-macos-mail/Cargo.toml new file mode 100644 index 00000000..c8ca6c12 --- /dev/null +++ b/crates/khive-channel-macos-mail/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "khive-channel-macos-mail" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +description = "macOS Mail.app channel handler — reference khive-channel implementation using AppleScript / MailKit" + +[dependencies] +khive-types = { version = "0.2.0", path = "../khive-types", features = ["serde"] } +khive-channel = { version = "0.2.0", path = "../khive-channel" } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +chrono = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } diff --git a/crates/khive-channel-macos-mail/docs/reference/mail_bridge.applescript b/crates/khive-channel-macos-mail/docs/reference/mail_bridge.applescript new file mode 100644 index 00000000..7e6380d5 --- /dev/null +++ b/crates/khive-channel-macos-mail/docs/reference/mail_bridge.applescript @@ -0,0 +1,184 @@ +-- mail_bridge.applescript +-- Reads INBOX messages from Apple Mail and outputs JSON to stdout. +-- Usage: +-- osascript mail_bridge.applescript +-- osascript mail_bridge.applescript "account@example.com" +-- osascript mail_bridge.applescript "" 1714240000 +-- osascript mail_bridge.applescript "account@example.com" 1714240000 +-- +-- Parameters: +-- argv[1] : account_name (optional; sync all accounts if omitted or empty) +-- argv[2] : since_epoch (optional; Unix timestamp; only messages newer than this) + +on escapeJSON(rawText) + -- Escape characters that would break JSON string values. + set t to rawText + -- Backslash must come first. + set t to my replaceText(t, "\\", "\\\\") + set t to my replaceText(t, "\"", "\\\"") + set t to my replaceText(t, "/", "\\/") + set t to my replaceText(t, return, "\\n") + set t to my replaceText(t, linefeed, "\\n") + set t to my replaceText(t, tab, "\\t") + return t +end escapeJSON + +on replaceText(theText, searchStr, replacementStr) + set AppleScript's text item delimiters to searchStr + set textItems to text items of theText + set AppleScript's text item delimiters to replacementStr + set result to textItems as string + set AppleScript's text item delimiters to "" + return result +end replaceText + +on truncateText(theText, maxLen) + if (length of theText) > maxLen then + return (text 1 thru maxLen of theText) + end if + return theText +end truncateText + +on dateToEpoch(d) + -- Returns the Unix epoch for an AppleScript date. + -- AppleScript epoch is 2001-01-01 00:00:00 UTC; Unix epoch is 1970-01-01 00:00:00 UTC. + -- Difference: 978307200 seconds. + return (d - (date "Sunday, January 1, 2001 at 12:00:00 AM")) + 978307200 +end dateToEpoch + +on run argv + set filterAccount to "" + set sinceEpoch to 0 + + if (count of argv) >= 1 then + set filterAccount to item 1 of argv + end if + if (count of argv) >= 2 then + try + set sinceEpoch to item 2 of argv as integer + end try + end if + + set jsonParts to {} + set msgCount to 0 + + tell application "Mail" + set allAccounts to every account + + repeat with acct in allAccounts + set acctName to name of acct + + -- If a specific account was requested, skip others. + if filterAccount is not "" and filterAccount is not acctName then + -- continue (AppleScript has no continue; use conditional) + else + -- Find the INBOX mailbox (case-insensitive: "INBOX" for Gmail, "Inbox" for Outlook) + set inboxMbox to missing value + try + set allMailboxes to every mailbox of acct + repeat with mbox in allMailboxes + set mboxName to name of mbox + if mboxName is "INBOX" or mboxName is "Inbox" then + set inboxMbox to mbox + exit repeat + end if + end repeat + end try + + if inboxMbox is not missing value then + try + set allMessages to every message of inboxMbox + + repeat with msg in allMessages + try + set msgDate to date received of msg + set epochSecs to my dateToEpoch(msgDate) + + -- Apply since_epoch filter if provided. + if sinceEpoch > 0 and epochSecs <= sinceEpoch then + -- skip this message + else + -- Collect fields. + set msgId to message id of msg + if msgId is missing value then set msgId to "" + + set msgSubject to subject of msg + if msgSubject is missing value then set msgSubject to "" + + set msgIsRead to read status of msg + + -- Sender + set senderName to "" + set senderAddr to "" + try + set senderAddr to sender of msg + end try + try + -- "Full Name " format — extract name portion if present. + if senderAddr contains "<" then + set senderName to text 1 thru ((offset of "<" in senderAddr) - 2) of senderAddr + set addrStart to (offset of "<" in senderAddr) + 1 + set addrEnd to (offset of ">" in senderAddr) - 1 + set senderAddr to text addrStart thru addrEnd of senderAddr + end if + end try + + -- Recipients: build comma-separated list for JSON array. + set toList to {} + try + set toRecips to to recipients of msg + repeat with r in toRecips + try + set rAddr to address of r + set end of toList to "\"" & my escapeJSON(rAddr) & "\"" + end try + end repeat + end try + set toJSON to "[" & my joinList(toList, ",") & "]" + + -- Body (plain text, truncated). + set msgBody to "" + try + set msgBody to content of msg + set msgBody to my truncateText(msgBody, 8192) + end try + + -- Build JSON object. + set isReadStr to "false" + if msgIsRead then set isReadStr to "true" + + set jsonObj to "{" & ¬ + "\"message_id\":\"" & my escapeJSON(msgId) & "\"," & ¬ + "\"date_received\":" & epochSecs & "," & ¬ + "\"from_name\":\"" & my escapeJSON(senderName) & "\"," & ¬ + "\"from_address\":\"" & my escapeJSON(senderAddr) & "\"," & ¬ + "\"to_addresses\":" & toJSON & "," & ¬ + "\"subject\":\"" & my escapeJSON(msgSubject) & "\"," & ¬ + "\"body_plain\":\"" & my escapeJSON(msgBody) & "\"," & ¬ + "\"is_read\":" & isReadStr & "," & ¬ + "\"account\":\"" & my escapeJSON(acctName) & "\"," & ¬ + "\"mailbox\":\"INBOX\"" & ¬ + "}" + + set end of jsonParts to jsonObj + set msgCount to msgCount + 1 + end if + end try + end repeat + end try + end if + end if + end repeat + end tell + + -- Output JSON array. + set jsonOutput to "[" & my joinList(jsonParts, ",") & "]" + return jsonOutput +end run + +on joinList(theList, delimiter) + set AppleScript's text item delimiters to delimiter + set result to theList as string + set AppleScript's text item delimiters to "" + return result +end joinList diff --git a/crates/khive-channel-macos-mail/docs/reference/test_mail_sync.sh b/crates/khive-channel-macos-mail/docs/reference/test_mail_sync.sh new file mode 100644 index 00000000..f8cc052b --- /dev/null +++ b/crates/khive-channel-macos-mail/docs/reference/test_mail_sync.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# test_mail_sync.sh +# Phase 1 pipeline smoke test: Apple Mail → khive communication service. +# +# Usage: +# ./scripts/test_mail_sync.sh +# ACCOUNT="quantocean.li@gmail.com" SINCE=1714240000 ./scripts/test_mail_sync.sh +# +# Environment variables: +# ACCOUNT (optional) Mail account name to filter; syncs all if omitted. +# SINCE (optional) Unix epoch timestamp; only messages newer than this are synced. +# DRY_RUN Set to "1" to print the batch payload without sending to khived. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MAIL_BRIDGE="${SCRIPT_DIR}/mail_bridge.applescript" +ACCOUNT="${ACCOUNT:-}" +SINCE="${SINCE:-0}" +DRY_RUN="${DRY_RUN:-0}" + +# ---- dependency checks ------------------------------------------------------- + +if ! command -v jq &>/dev/null; then + echo "ERROR: jq is required but not found. Install with: brew install jq" >&2 + exit 1 +fi + +if ! command -v khived &>/dev/null; then + echo "ERROR: khived is required but not found. Ensure it is in PATH." >&2 + exit 1 +fi + +if [[ ! -f "${MAIL_BRIDGE}" ]]; then + echo "ERROR: mail_bridge.applescript not found at ${MAIL_BRIDGE}" >&2 + exit 1 +fi + +# ---- fetch messages ---------------------------------------------------------- + +echo "Fetching INBOX messages from Apple Mail..." +if [[ -n "${ACCOUNT}" ]]; then + echo " Account filter : ${ACCOUNT}" +fi +if [[ "${SINCE}" -gt 0 ]]; then + echo " Since epoch : ${SINCE}" +fi + +# osascript parameters: account_name (may be empty string), since_epoch +MAIL_JSON=$(osascript "${MAIL_BRIDGE}" "${ACCOUNT}" "${SINCE}" 2>&1) || { + echo "ERROR: osascript failed:" >&2 + echo "${MAIL_JSON}" >&2 + exit 1 +} + +# Validate JSON output. +if ! echo "${MAIL_JSON}" | jq empty 2>/dev/null; then + echo "ERROR: mail_bridge returned invalid JSON:" >&2 + echo "${MAIL_JSON}" | head -20 >&2 + exit 1 +fi + +MSG_COUNT=$(echo "${MAIL_JSON}" | jq 'length') +echo "Found ${MSG_COUNT} message(s)." + +if [[ "${MSG_COUNT}" -eq 0 ]]; then + echo "Nothing to sync." + exit 0 +fi + +# ---- sync to khive ----------------------------------------------------------- + +SYNCED=0 +FAILED=0 + +while IFS= read -r MSG_JSON; do + # Extract fields. + MSG_ID=$(echo "${MSG_JSON}" | jq -r '.message_id') + SUBJECT=$(echo "${MSG_JSON}" | jq -r '.subject') + BODY=$(echo "${MSG_JSON}" | jq -r '.body_plain') + FROM=$(echo "${MSG_JSON}" | jq -r '.from_address') + ACCOUNT_VAL=$(echo "${MSG_JSON}" | jq -r '.account') + IS_READ=$(echo "${MSG_JSON}" | jq -r '.is_read') + DATE_R=$(echo "${MSG_JSON}" | jq -r '.date_received') + + # Build the batch payload using jq to guarantee valid JSON escaping. + PAYLOAD=$(jq -n \ + --arg subject "${SUBJECT}" \ + --arg body "${BODY}" \ + --arg msg_id "${MSG_ID}" \ + --arg from "${FROM}" \ + --arg account "${ACCOUNT_VAL}" \ + --argjson is_read "${IS_READ}" \ + --argjson date_received "${DATE_R}" \ + '[{ + "service": "communication", + "action": "send", + "args": { + "to": "lambda:khive", + "content": $body, + "subject": $subject, + "kind": "fyi", + "from": "external:mail", + "metadata": { + "mail_message_id": $msg_id, + "from_addr": $from, + "account": $account, + "is_read": $is_read, + "date_received": $date_received + } + } + }]' + ) + + if [[ "${DRY_RUN}" == "1" ]]; then + echo "[DRY RUN] Would send:" + echo "${PAYLOAD}" | jq . + SYNCED=$((SYNCED + 1)) + continue + fi + + # Send via khived batch. + RESPONSE=$(khived batch --json "${PAYLOAD}" 2>&1) || { + echo "WARNING: Failed to sync message id=${MSG_ID}: ${RESPONSE}" >&2 + FAILED=$((FAILED + 1)) + continue + } + + SYNCED=$((SYNCED + 1)) + +done < <(echo "${MAIL_JSON}" | jq -c '.[]') + +# ---- report ------------------------------------------------------------------ + +echo "" +echo "Sync complete." +echo " Synced : ${SYNCED}" +echo " Failed : ${FAILED}" + +if [[ "${FAILED}" -gt 0 ]]; then + exit 1 +fi diff --git a/crates/khive-channel-macos-mail/src/lib.rs b/crates/khive-channel-macos-mail/src/lib.rs new file mode 100644 index 00000000..c9fa0751 --- /dev/null +++ b/crates/khive-channel-macos-mail/src/lib.rs @@ -0,0 +1,15 @@ +//! khive-channel-macos-mail — macOS Mail.app implementation of [`khive_channel::ChannelHandler`]. +//! +//! Local-first email integration. Uses the user's existing Mail.app accounts +//! (no separate OAuth setup, no third-party servers). Bridges via AppleScript +//! or MailKit (Sequoia+). +//! +//! Demonstrates the "use the OS, not the cloud" path for personal-scale +//! agents — credentials and message bodies never leave the device. +//! +//! Scaffold only — AppleScript bridge / MailKit FFI, send semantics, and +//! fetch/poll wiring are deferred (see +//! `.khive/notes/strategy_20260522_open_core_split.md`). + +// TODO(post-MVP): gate macOS-only build with `#[cfg(target_os = "macos")]` +// once the implementation lands, so this crate is buildable on non-Mac CI. diff --git a/crates/khive-channel/Cargo.toml b/crates/khive-channel/Cargo.toml new file mode 100644 index 00000000..b364522a --- /dev/null +++ b/crates/khive-channel/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "khive-channel" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +description = "Channel abstraction — the ChannelHandler trait and message envelope types consumed by khive-pack-comm" + +[dependencies] +khive-types = { version = "0.2.0", path = "../khive-types", features = ["serde"] } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } diff --git a/crates/khive-channel/src/lib.rs b/crates/khive-channel/src/lib.rs new file mode 100644 index 00000000..fd399acc --- /dev/null +++ b/crates/khive-channel/src/lib.rs @@ -0,0 +1,10 @@ +//! khive-channel — channel abstraction layer. +//! +//! Defines the [`ChannelHandler`] trait that concrete channel crates +//! (`khive-channel-gmail`, `khive-channel-slack`, ...) implement. Consumed by +//! `khive-pack-comm` to dispatch communication verbs (`send`, `inbox`, ...) +//! against a concrete channel. +//! +//! Scaffold only — detailed trait surface, message envelope shape, and +//! registration mechanism are deferred (see +//! `.khive/notes/strategy_20260522_open_core_split.md`). diff --git a/crates/khive-pack-comm/Cargo.toml b/crates/khive-pack-comm/Cargo.toml new file mode 100644 index 00000000..a65f0482 --- /dev/null +++ b/crates/khive-pack-comm/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "khive-pack-comm" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +description = "Communication verb pack — declares the message note kind, allowed edges, and routes verbs to channel implementations" + +[dependencies] +khive-types = { version = "0.2.0", path = "../khive-types", features = ["serde"] } +khive-runtime = { version = "0.2.0", path = "../khive-runtime" } +khive-channel = { version = "0.2.0", path = "../khive-channel" } +inventory = { workspace = true } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } +tracing = { workspace = true } +chrono = { workspace = true } diff --git a/crates/khive-pack-comm/src/lib.rs b/crates/khive-pack-comm/src/lib.rs new file mode 100644 index 00000000..94439017 --- /dev/null +++ b/crates/khive-pack-comm/src/lib.rs @@ -0,0 +1,20 @@ +//! khive-pack-comm — communication verb pack. +//! +//! Declares: +//! - `note_kind = "message"` (pack-owned per ADR-025; not an ADR-019 amendment) +//! - allowed edge endpoints for `message` notes via `EDGE_RULES` (ADR-031, additive only) +//! - the note-status set for messages (TBD) +//! - the verb wiring (`send`, `inbox`, `read`, `mark_read`, ...) over [`khive_runtime::pack::PackRuntime`] +//! +//! Channel implementations live in separate crates (`khive-channel-gmail`, +//! `khive-channel-slack`, ...) and satisfy the [`khive_channel::ChannelHandler`] +//! trait. This pack routes verbs to the registered channel. +//! +//! **Security**: OSS pack-comm does not enforce gating. Channels (OSS or +//! third-party) handle their own auth/scope. Cloud-hosted channels go through +//! the formal gate runtime — that is the commercial differentiator, not a +//! flaw in the OSS design. +//! +//! Scaffold only — note status, edge endpoints, handler trait, and verb +//! handlers are deferred (see +//! `.khive/notes/strategy_20260522_open_core_split.md`).