{event.detail}
@@ -346,6 +375,8 @@ export function WhatsAppPairing({
bridgeUrl = "http://localhost:3100",
onConnected,
onDisconnected,
+ agentAvatarUrl,
+ agentName,
}: WhatsAppPairingProps) {
const toast = useToast();
@@ -1080,6 +1111,8 @@ export function WhatsAppPairing({
onDisconnect={handleDisconnect}
onStartTest={() => setTestMode("waiting")}
onClearTest={() => setTestMode("idle")}
+ agentAvatarUrl={agentAvatarUrl}
+ agentName={agentName}
/>
{/* Mobile commands — the killer feature for verifying bridge
@@ -1375,6 +1408,8 @@ function ConnectedPanel({
onDisconnect,
onStartTest,
onClearTest,
+ agentAvatarUrl,
+ agentName,
}: {
phoneNumber: string | null;
displayName: string | null;
@@ -1398,6 +1433,8 @@ function ConnectedPanel({
onDisconnect: () => void;
onStartTest: () => void;
onClearTest: () => void;
+ agentAvatarUrl?: string;
+ agentName?: string;
}) {
const muted = botState?.muted ?? false;
const initials = useMemo(
@@ -1610,6 +1647,8 @@ function ConnectedPanel({
now={now}
onPauseContact={onPauseContact}
pausing={pausingContact}
+ agentAvatarUrl={agentAvatarUrl}
+ agentName={agentName}
/>
))}
diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts
index 49d8c941..84816d31 100644
--- a/apps/web/lib/api.ts
+++ b/apps/web/lib/api.ts
@@ -437,7 +437,7 @@ class LanternAPI {
async updateAgent(
name: string,
- data: { description?: string; systemPrompt?: string; model?: string; isolation?: string; timeout?: string; maxTokens?: number; maxCostUsd?: number; cron?: string },
+ data: { description?: string; systemPrompt?: string; model?: string; isolation?: string; timeout?: string; maxTokens?: number; maxCostUsd?: number; cron?: string; avatarUrl?: string; stylePrompt?: string },
): Promise {
return this.request(
`/v1/agents/${encodeURIComponent(name)}`,
diff --git a/apps/web/lib/mock-data.ts b/apps/web/lib/mock-data.ts
index ad938dc3..409d8775 100644
--- a/apps/web/lib/mock-data.ts
+++ b/apps/web/lib/mock-data.ts
@@ -14,6 +14,9 @@ export interface Agent {
createdAt: Date;
labels: Record;
status: "active" | "archived";
+ avatarUrl?: string;
+ stylePrompt?: string;
+ systemPrompt?: string;
}
export interface AgentVersion {
diff --git a/services/control-plane/internal/db/migrate.go b/services/control-plane/internal/db/migrate.go
index 94914e9a..2e4660ca 100644
--- a/services/control-plane/internal/db/migrate.go
+++ b/services/control-plane/internal/db/migrate.go
@@ -399,6 +399,27 @@ var migrations = []string{
END IF;
END$$`,
+ // ---------------------------------------------------------------
+ // Avatar + per-agent style prompt. Avatar is a URL (we may upload
+ // to MinIO later and store the ref). style_prompt overrides the
+ // bridge's baseline persona; it captures "my voice" rules.
+ // ---------------------------------------------------------------
+ `DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'agents' AND column_name = 'avatar_url'
+ ) THEN
+ ALTER TABLE agents ADD COLUMN avatar_url TEXT;
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'agents' AND column_name = 'style_prompt'
+ ) THEN
+ ALTER TABLE agents ADD COLUMN style_prompt TEXT;
+ END IF;
+ END$$`,
+
// ---------------------------------------------------------------
// Agent budgets — policy-as-code per-tool rate + cost limits.
// Enforced at step-executor before any LLM call or tool dispatch.
diff --git a/services/control-plane/internal/handlers/rest.go b/services/control-plane/internal/handlers/rest.go
index 19e8aff6..c9663524 100644
--- a/services/control-plane/internal/handlers/rest.go
+++ b/services/control-plane/internal/handlers/rest.go
@@ -767,95 +767,13 @@ func (h *RESTHandler) executeRunInline(runID, tenantID, agentName string, input
prompt = fmt.Sprintf("%s\n\n%s", systemPromptStr, userContent)
}
- // 2b. Check if this agent has Gmail in its connector config.
- // The agent's connector list is stored in the input JSON under "connectors"
- // or we check if the agent has a Gmail connector explicitly linked.
- // For now, check the input for a "useGmail" flag, or check if the agent
- // description/labels mention email. The proper fix: read agent's connector
- // config from a dedicated table.
- agentUsesGmail := false
- if connectors, ok := input["connectors"]; ok {
- if connList, ok := connectors.([]any); ok {
- for _, c := range connList {
- if cs, ok := c.(string); ok && (cs == "Gmail" || cs == "gmail") {
- agentUsesGmail = true
- }
- }
- }
- }
- // Also check if explicitly requested in the input
- if _, ok := input["fetchEmails"]; ok {
- agentUsesGmail = true
- }
-
- var gmailToken string
- if agentUsesGmail {
- logStep("fetch_data", "running", "Fetching data from connected sources")
- gmailToken = resolveGmailToken(ctx, h.srv.Pool, tenantID)
- // Try to refresh the token before use
- if gmailToken != "" {
- var refreshToken string
- var oauthJSON []byte
- _ = h.srv.Pool.QueryRow(ctx,
- `SELECT oauth_token_encrypted FROM connector_installs WHERE tenant_id = $1 AND connector_id = 'gmail' AND status = 'connected'`,
- tenantID,
- ).Scan(&oauthJSON)
- if len(oauthJSON) > 0 {
- var tok map[string]any
- if json.Unmarshal(oauthJSON, &tok) == nil {
- if rt, ok := tok["refresh_token"].(string); ok { refreshToken = rt }
- }
- }
- if refreshToken != "" {
- if newToken, err := refreshGoogleToken(refreshToken); err == nil && newToken != "" {
- gmailToken = newToken
- updatedOAuth, _ := json.Marshal(map[string]any{"access_token": newToken, "refresh_token": refreshToken, "token_type": "Bearer"})
- _, _ = h.srv.Pool.Exec(ctx, `UPDATE connector_installs SET oauth_token_encrypted = $1 WHERE tenant_id = $2 AND connector_id = 'gmail'`, string(updatedOAuth), tenantID)
- }
- }
- }
- if gmailToken != "" {
- logStep("fetch_gmail", "running", "Fetching emails from Gmail API")
- emails, fetchErr := FetchGmailViaAPI(gmailToken, 20)
- if fetchErr == nil && len(emails) > 0 {
- logStep("fetch_gmail", "completed", fmt.Sprintf("Fetched %d emails", len(emails)))
- var emailText strings.Builder
- emailText.WriteString("\n\nHere are the user's recent emails:\n\n")
- for i, e := range emails {
- emailText.WriteString(fmt.Sprintf("%d. From: %s\n Subject: %s\n Preview: %s\n Date: %s\n\n",
- i+1, e.From, e.Subject, e.Snippet, e.Date))
- }
- prompt += emailText.String()
- } else if fetchErr != nil {
- h.logger().Warn("inline executor: Gmail fetch failed, continuing without emails",
- zap.String("run_id", runID), zap.Error(fetchErr))
- }
- } else {
- // Try IMAP with email + app password
- var email, appPassword string
- _ = h.srv.Pool.QueryRow(ctx,
- `SELECT config->>'email', config->>'appPassword' FROM connector_installs WHERE tenant_id = $1 AND connector_id = 'gmail' AND status = 'connected'`,
- tenantID,
- ).Scan(&email, &appPassword)
-
- if email != "" && appPassword != "" {
- emails, fetchErr := FetchGmailViaIMAP(email, appPassword, 20)
- if fetchErr == nil && len(emails) > 0 {
- var emailText strings.Builder
- emailText.WriteString("\n\nHere are the user's recent emails (fetched via IMAP):\n\n")
- for i, e := range emails {
- emailText.WriteString(fmt.Sprintf("%d. From: %s\n Subject: %s\n Preview: %s\n Date: %s\n\n",
- i+1, e.From, e.Subject, e.Snippet, e.Date))
- }
- prompt += emailText.String()
- } else if fetchErr != nil {
- h.logger().Warn("inline executor: IMAP Gmail fetch failed", zap.String("run_id", runID), zap.Error(fetchErr))
- }
- } else {
- prompt += "\n\nNote: Gmail is not connected. The user may paste email content manually, or you should explain how to connect Gmail."
- }
- }
- } // end isEmailAgent
+ // (Dead pre-prefetch-era Gmail-injection branch removed. All
+ // template-driven data fetching now goes through prefetchForTemplate
+ // in template_prefetch.go — see Source B in prefetchMorningBrief /
+ // prefetchInboxConcierge. Custom agents that need Gmail data go
+ // through the tool-use loop, where the model calls gmail__search /
+ // gmail__list_messages explicitly. No more ad-hoc input-flag
+ // inspection inside executeRunInline.)
// 3. Call the LLM. Use the tenant-aware resolver so auto-routing picks
// from providers configured in Settings, not just the process env.
diff --git a/services/whatsapp-bridge/src/agent.ts b/services/whatsapp-bridge/src/agent.ts
index 270fd6b1..4e71e02e 100644
--- a/services/whatsapp-bridge/src/agent.ts
+++ b/services/whatsapp-bridge/src/agent.ts
@@ -14,6 +14,12 @@ export class AgentClient {
private sessions: Map = new Map();
// jid -> pending reply resolver (prevents overlapping requests from one contact)
private inflight: Map> = new Map();
+ // Cached per-agent style prompt fetched from the control-plane. Refreshed
+ // lazily with a small TTL so edits to the "my voice" textarea on the
+ // dashboard show up within ~30s without a per-turn round trip.
+ private cachedStylePrompt: string | undefined = undefined;
+ private styleFetchedAt = 0;
+ private static readonly STYLE_TTL_MS = 30_000;
constructor(logger: Logger, tenantAuthDir: string) {
this.agentName = process.env.LANTERN_AGENT_NAME || "whatsapp-assistant";
@@ -184,6 +190,33 @@ export class AgentClient {
this.saveSessions();
}
+ // Fetch the agent's style_prompt from the control-plane, cached for
+ // STYLE_TTL_MS. Returns undefined when not set or unreachable.
+ async getStylePrompt(): Promise {
+ if (!this.enabled()) return undefined;
+ if (Date.now() - this.styleFetchedAt < AgentClient.STYLE_TTL_MS) {
+ return this.cachedStylePrompt;
+ }
+ try {
+ const res = await authedFetch(
+ `/v1/agents/${encodeURIComponent(this.agentName)}`,
+ { headers: { Accept: "application/json" } }
+ );
+ if (!res.ok) {
+ this.styleFetchedAt = Date.now();
+ return this.cachedStylePrompt;
+ }
+ const data = (await res.json()) as { stylePrompt?: string };
+ this.cachedStylePrompt = data.stylePrompt?.trim() || undefined;
+ this.styleFetchedAt = Date.now();
+ return this.cachedStylePrompt;
+ } catch (err) {
+ this.logger.warn({ err }, "fetch agent stylePrompt failed");
+ this.styleFetchedAt = Date.now();
+ return this.cachedStylePrompt;
+ }
+ }
+
private loadSessions() {
if (!existsSync(this.sessionsFile)) return;
try {
diff --git a/services/whatsapp-bridge/src/natural.ts b/services/whatsapp-bridge/src/natural.ts
index 4d6ecd7d..4700193d 100644
--- a/services/whatsapp-bridge/src/natural.ts
+++ b/services/whatsapp-bridge/src/natural.ts
@@ -250,10 +250,27 @@ export function inferStyle(messages: string[]): StyleProfile {
* verbatim — that's creepy and breaks under mixed registers. We just
* tell it the conversational register so it doesn't pivot to corporate.
*/
+export interface PersonaOptions {
+ // Last N messages the owner ACTUALLY sent this contact. Used as
+ // few-shot exemplars so the LLM matches their real voice instead of
+ // a generic "casual texter". Capped to a small handful so the prompt
+ // stays compact.
+ ownerSamples?: string[];
+ // True once the bridge has sent the one-line handoff disclosure on
+ // this thread. When disclosed, the persona allows soft self-identification
+ // ("the assistant") and drops the strict impersonation rules.
+ disclosed?: boolean;
+ // Per-agent style override (the "my voice" textarea on the agent detail
+ // page). Concatenated verbatim at the end of the persona — power-user
+ // hand-tuning beats inferred cues.
+ stylePrompt?: string;
+}
+
export function agentPersonaPrompt(
ownerName: string,
style: StyleProfile,
- isGroup: boolean
+ isGroup: boolean,
+ opts: PersonaOptions = {}
): string {
const cues: string[] = [];
if (style.formality === "casual") {
@@ -269,27 +286,58 @@ export function agentPersonaPrompt(
cues.push("they send very short messages — keep replies short too, often one line.");
}
- return [
- `You are texting on WhatsApp AS ${ownerName}. The other person believes they are talking to ${ownerName}, not to an AI.`,
- ``,
- `Sound exactly like a real person texting. NOT like a chatbot or assistant.`,
+ const intro = opts.disclosed
+ ? `You are ${ownerName}'s assistant texting on WhatsApp. The contact has already been told you're an assistant covering for ${ownerName}, so don't impersonate them — but match their tone exactly so the thread reads naturally.`
+ : `You are texting on WhatsApp AS ${ownerName}. Sound exactly like a real person texting, not like a chatbot.`;
+
+ const identityRule = opts.disclosed
+ ? `- You may say "I'm ${ownerName}'s assistant" if asked directly. Otherwise don't bring it up.`
+ : `- Never identify yourself as an AI, assistant, bot, or language model.`;
+
+ const lines: string[] = [
+ intro,
``,
- `Hard rules:`,
- `- Never identify yourself as an AI, assistant, bot, or language model.`,
- `- Never apologize for being an AI.`,
+ `Hard rules — non-negotiable:`,
+ identityRule,
+ `- Never apologize for being slow, being an AI, or being a bot.`,
`- Never say "How can I help you today?", "I'd be happy to assist", "Certainly!", "Of course!", "Great question", "Let me know if you need anything else", or anything that signals customer service.`,
- `- No bullet lists, no markdown formatting, no headers. This is a text message, not a Notion page.`,
- `- Short messages are better. 1-2 lines unless they asked something complex.`,
- `- It's fine to send a single word ("yeah", "ok", "haha", "true").`,
- `- It's fine NOT to end a message with a period.`,
- `- When you don't know, just say "not sure" or "lemme check" — don't invent an answer.`,
+ `- Plain text only. No markdown, no bullets, no headers, no em-dashes (—). This is a text message, not a Notion page.`,
+ `- Keep replies SHORT. Default to one line, under 15 words. Two lines only when the question genuinely needs it.`,
+ `- Single-word replies are fine ("yeah", "ok", "true", "lol").`,
+ `- Use contractions ("I'll", "can't", "don't", "won't"). Not "I will" / "cannot".`,
+ `- Don't end every line with a period.`,
+ `- When you don't know, say "not sure" or "lemme check" — don't invent.`,
+ `- Skip greetings and signoffs unless the contact opens with one. No "Hi!" no "Best,".`,
`- ${isGroup ? "You are in a group chat — be brief and only reply when directly addressed." : "This is a 1-on-1 thread."}`,
``,
- `Style notes for this thread:`,
+ `Inferred style for this thread:`,
...(cues.length > 0 ? cues.map((c) => `- ${c}`) : ["- no strong signal yet — keep it neutral and casual."]),
- ``,
- `Reply as ${ownerName}, in plain text, no quoting, no preface.`,
- ].join("\n");
+ ];
+
+ const samples = (opts.ownerSamples ?? [])
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0 && s.length <= 280)
+ .slice(-8);
+ if (samples.length > 0) {
+ lines.push(``);
+ lines.push(`Examples of how ${ownerName} actually writes (match this voice — length, casing, vocabulary, punctuation):`);
+ for (const s of samples) lines.push(`> ${s}`);
+ }
+
+ const override = opts.stylePrompt?.trim();
+ if (override) {
+ lines.push(``);
+ lines.push(`Style overrides (these take precedence over the rules above):`);
+ lines.push(override);
+ }
+
+ lines.push(``);
+ lines.push(
+ opts.disclosed
+ ? `Reply in plain text, in ${ownerName}'s voice, no preface, no signature.`
+ : `Reply as ${ownerName}, in plain text, no quoting, no preface.`
+ );
+ return lines.join("\n");
}
// ---------------------------------------------------------------------------
diff --git a/services/whatsapp-bridge/src/session.ts b/services/whatsapp-bridge/src/session.ts
index d029d1a9..6b340462 100644
--- a/services/whatsapp-bridge/src/session.ts
+++ b/services/whatsapp-bridge/src/session.ts
@@ -1271,11 +1271,16 @@ export class WhatsAppSession {
const ownerSamples = opts.isGroup
? []
: this.ownerSentHistory.get(from) ?? [];
+ const stylePrompt = await this.agent.getStylePrompt();
const systemHint = agentPersonaPrompt(
ownerName,
style,
!!opts.isGroup,
- { ownerSamples, disclosed: this.disclosedJids.has(from) }
+ {
+ ownerSamples,
+ disclosed: this.disclosedJids.has(from),
+ stylePrompt,
+ }
);
// In groups, annotate the user message so the agent knows it's in a group
@@ -1386,6 +1391,25 @@ export class WhatsAppSession {
}
}
+ // Remember a message the owner just typed from their phone in a 1:1 thread.
+ // Drives the few-shot exemplars in the persona — "this is how the human
+ // actually writes". Skipped for the self-chat, groups, and slash commands
+ // upstream. We persist with state so the buffer survives restarts.
+ private rememberOwnerSent(jid: string, text: string) {
+ const t = text.trim();
+ if (!t || t.length > 280) return;
+ let bucket = this.ownerSentHistory.get(jid);
+ if (!bucket) {
+ bucket = [];
+ this.ownerSentHistory.set(jid, bucket);
+ }
+ bucket.push(t);
+ if (bucket.length > WhatsAppSession.OWNER_SENT_PER_CONTACT) {
+ bucket.splice(0, bucket.length - WhatsAppSession.OWNER_SENT_PER_CONTACT);
+ }
+ this.saveState();
+ }
+
async disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
From a5b5e70626e2bc43c2aff59fdf0cdb56b6695df6 Mon Sep 17 00:00:00 2001
From: Shekhar Mudarapu
Date: Tue, 19 May 2026 23:14:08 -0400
Subject: [PATCH 002/742] Slack /lantern command + isRunNowInput detects
metadata-only Run Now
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
slack_command.go (NEW)
- POST /v1/surfaces/slack/command — Slack slash command handler.
- Verifies X-Slack-Signature when SLACK_SIGNING_SECRET is set; in dev
(no secret) accepts unverified + logs warning so ngrok-based
iteration works.
- Subcommands: status (uptime + surface/connector/agent count + last
run), ping (round-trip liveness), agents (top 15 by recency), help.
- All replies ephemeral so only the invoking user sees them.
- Tenant resolution: looks up surface_configs.config.workspaceId by
team_id, falls back to dev tenant when not configured so /lantern
works on seeded data without setup.
rest.go::isRunNowInput
- Old check was: input map empty || raw == '{}' || raw == 'null'.
- Real-world: dashboard sends {"connectors":[]} (or similar metadata-
only payloads) for Run Now. Old check returned false on those,
prefetch logic stayed dormant, model got tools instead of prefetched
data, gaslighted user about Gmail being disconnected.
- New helper recognizes "no real instruction": checks known
instruction field names (prompt/message/text/query/input/instruction
/ask), then falls through to "ignore metadata keys (connectors,
surfaces, tags, labels, model, stream) and check if any other
string field has content". Returns true (= 'effectively empty,
apply Run Now default') for the dashboard's metadata-only payloads.
- Wired into the executeRunInline default-input branch.
rest.go: remove dead pre-prefetch Gmail branch
- The ~85 lines of input-flag-driven Gmail fetching that template_
prefetch.go now owns. resolveGmailToken / FetchGmailViaAPI / IMAP
fallback all live in the per-template prefetch handler instead.
- Custom agents that need Gmail go through the tool-use loop where
the model explicitly calls gmail__search / gmail__list_messages.
---
services/control-plane/cmd/server/main.go | 4 +
.../control-plane/internal/handlers/rest.go | 49 ++-
.../internal/handlers/slack_command.go | 290 ++++++++++++++++++
3 files changed, 339 insertions(+), 4 deletions(-)
create mode 100644 services/control-plane/internal/handlers/slack_command.go
diff --git a/services/control-plane/cmd/server/main.go b/services/control-plane/cmd/server/main.go
index a02c854f..9c5812fb 100644
--- a/services/control-plane/cmd/server/main.go
+++ b/services/control-plane/cmd/server/main.go
@@ -222,6 +222,10 @@ func main() {
// matchers so they're not shadowed.
httpMux.HandleFunc("POST /v1/surfaces/whatsapp/heartbeat", surfaceHandler.BridgeHeartbeat)
httpMux.HandleFunc("GET /v1/surfaces/whatsapp/status", surfaceHandler.WhatsAppStatus)
+ // Slack /lantern slash command. Signed via SLACK_SIGNING_SECRET when
+ // configured (dev accepts unverified + warns); ephemeral JSON replies.
+ slackCommandHandler := handlers.NewSlackCommandHandler(srv)
+ httpMux.HandleFunc("POST /v1/surfaces/slack/command", slackCommandHandler.HandleCommand)
httpMux.HandleFunc("PUT /v1/surfaces/{id}", surfaceHandler.UpdateSurface)
httpMux.HandleFunc("DELETE /v1/surfaces/{id}", surfaceHandler.RemoveSurface)
httpMux.HandleFunc("POST /v1/surfaces/{id}/test", surfaceHandler.TestSurface)
diff --git a/services/control-plane/internal/handlers/rest.go b/services/control-plane/internal/handlers/rest.go
index c9663524..dfbc158c 100644
--- a/services/control-plane/internal/handlers/rest.go
+++ b/services/control-plane/internal/handlers/rest.go
@@ -624,6 +624,41 @@ func (h *RESTHandler) SetLlmProxy(proxy *LlmProxyHandler) {
// executeRunInline processes a run in the background by calling the LLM
// directly. This is the spike-mode "workflow engine" — in production, the
// separate workflow-engine service handles this with durable execution.
+// isRunNowInput returns true when the run's input has no real user
+// instruction — Run Now from the dashboard, cron-triggered runs, etc.
+// "Real instruction" means at least one non-empty string field OR a
+// 'prompt'/'message'/'text'/'query'/'input' key. The dashboard sends
+// shaped metadata like {"connectors":[]} for Run Now invocations, which
+// is NOT a user instruction; treat it as empty so the executor's
+// synthesized default + prefetch logic kicks in.
+func isRunNowInput(input map[string]any, raw string) bool {
+ if len(input) == 0 || raw == "" || raw == "{}" || raw == "null" {
+ return true
+ }
+ // Known user-instruction field names. If any is present with non-empty
+ // content, this is a real user message.
+ for _, key := range []string{"prompt", "message", "text", "query", "input", "instruction", "ask"} {
+ if v, ok := input[key]; ok {
+ if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
+ return false
+ }
+ }
+ }
+ // No known instruction field. Treat as Run Now unless there's some
+ // other meaningful string content we haven't named.
+ for k, v := range input {
+ // Skip pure-metadata keys the dashboard adds.
+ switch k {
+ case "connectors", "surfaces", "tags", "labels", "model", "stream":
+ continue
+ }
+ if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
+ return false
+ }
+ }
+ return true
+}
+
func (h *RESTHandler) executeRunInline(runID, tenantID, agentName string, input map[string]any) {
ctx := context.Background()
ctx = middleware.InjectTenantID(ctx, tenantID)
@@ -736,11 +771,17 @@ func (h *RESTHandler) executeRunInline(runID, tenantID, agentName string, input
logStep("backfill_prompt", "completed", fmt.Sprintf("Restored system prompt from template '%s'", tpl.ID))
}
}
- // User-side content. When called via Run Now with no input, give the
- // model a sensible default ask matching the template's intent rather
- // than feeding it `{}` which it interprets as "input is empty".
+ // User-side content. When called via Run Now with no real user
+ // instruction, synthesize a sensible default ask matching the
+ // template's intent rather than handing the model an empty/metadata
+ // blob which it interprets as "input is empty".
+ //
+ // "No real instruction" includes more than just `{}`/`null` — the
+ // dashboard sometimes sends `{"connectors":[]}` (the agent's config
+ // shape, not a user message). Treat any input with no user-facing
+ // text fields as Run Now too.
userContent := string(inputJSON)
- if len(input) == 0 || userContent == "{}" || userContent == "null" {
+ if isRunNowInput(input, userContent) {
userContent = "This is a Run Now invocation — execute the workflow described in your system prompt right now. Call the tools you have available; do not ask me for clarification or claim you can't access anything."
}
prompt := fmt.Sprintf("%s\n\n%s", systemPromptStr, userContent)
diff --git a/services/control-plane/internal/handlers/slack_command.go b/services/control-plane/internal/handlers/slack_command.go
new file mode 100644
index 00000000..bb815a02
--- /dev/null
+++ b/services/control-plane/internal/handlers/slack_command.go
@@ -0,0 +1,290 @@
+package handlers
+
+// Slack /lantern slash command.
+//
+// Mirrors the WhatsApp /lantern command suite so Slack users can check
+// bridge / agent state from inside Slack the same way WhatsApp users do
+// from their phone.
+//
+// Setup (one-time, per workspace):
+// 1. https://api.slack.com/apps → your app → Slash Commands → Create New
+// Command name: /lantern
+// Request URL: https:///v1/surfaces/slack/command
+// Short desc: Lantern agent control
+// Usage hint: status | ping | agents | help
+// 2. Copy your app's Signing Secret into SLACK_SIGNING_SECRET on the
+// control-plane so we can verify request authenticity. In dev (no
+// secret set) we accept unverified requests + log a warning so
+// iteration via ngrok still works.
+//
+// Commands:
+// /lantern -> help
+// /lantern status -> uptime, configured surfaces, recent runs
+// /lantern ping -> liveness check (round-trip)
+// /lantern agents -> list of active agents in this tenant
+//
+// Slack expects a JSON response within 3s OR a 200 + async via response_url.
+// We answer synchronously since all handlers are sub-second DB reads.
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/dshakes/lantern/services/control-plane/internal/server"
+)
+
+type SlackCommandHandler struct {
+ srv *server.Server
+}
+
+func NewSlackCommandHandler(srv *server.Server) *SlackCommandHandler {
+ return &SlackCommandHandler{srv: srv}
+}
+
+func (h *SlackCommandHandler) logger() *zap.Logger {
+ return h.srv.Logger.Named("slack_command")
+}
+
+// HandleCommand is the entry point for POST /v1/surfaces/slack/command.
+// Slack sends application/x-www-form-urlencoded with: team_id, team_domain,
+// channel_id, channel_name, user_id, user_name, command (e.g. "/lantern"),
+// text (the subcommand + args), response_url, trigger_id.
+//
+// See https://api.slack.com/interactivity/slash-commands#app_command_handling
+func (h *SlackCommandHandler) HandleCommand(w http.ResponseWriter, r *http.Request) {
+ // Read raw body for signature verification.
+ bodyBytes, err := io.ReadAll(r.Body)
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, slackReply("Could not read request body."))
+ return
+ }
+
+ // Verify signature if SLACK_SIGNING_SECRET is configured. Refuse
+ // unverified requests in prod; in dev (no secret set) log a warning
+ // and proceed so the user can iterate locally via ngrok.
+ if signingSecret := os.Getenv("SLACK_SIGNING_SECRET"); signingSecret != "" {
+ if !verifySlackSignature(r, bodyBytes, signingSecret) {
+ h.logger().Warn("rejected slash command with invalid signature",
+ zap.String("ip", r.RemoteAddr))
+ writeJSON(w, http.StatusUnauthorized, slackReply("Signature verification failed."))
+ return
+ }
+ } else {
+ h.logger().Warn("SLACK_SIGNING_SECRET not set — accepting unverified slash command (dev only)")
+ }
+
+ form, err := url.ParseQuery(string(bodyBytes))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, slackReply("Could not parse slash-command payload."))
+ return
+ }
+ teamID := form.Get("team_id")
+ userName := form.Get("user_name")
+ text := strings.TrimSpace(form.Get("text"))
+
+ h.logger().Info("slack slash command",
+ zap.String("team_id", teamID),
+ zap.String("user", userName),
+ zap.String("text", text),
+ )
+
+ // Resolve the tenant. Look up by team_id stored in surface_configs
+ // config.workspaceId; fall back to the dev tenant so /lantern works
+ // out of the box on the seeded data.
+ tenantID := h.resolveTenantFromSlackTeam(r.Context(), teamID)
+
+ parts := strings.Fields(text)
+ sub := ""
+ if len(parts) > 0 {
+ sub = strings.ToLower(parts[0])
+ }
+
+ switch sub {
+ case "", "help":
+ writeJSON(w, http.StatusOK, slackReply(slackHelpBlock()))
+ case "ping":
+ writeJSON(w, http.StatusOK, slackReply(":table_tennis_paddle_and_ball: pong — control-plane is alive"))
+ case "status":
+ writeJSON(w, http.StatusOK, slackReply(h.statusReply(r.Context(), tenantID)))
+ case "agents":
+ writeJSON(w, http.StatusOK, slackReply(h.agentsReply(r.Context(), tenantID)))
+ default:
+ writeJSON(w, http.StatusOK, slackReply(
+ fmt.Sprintf("Unknown subcommand `%s`. Try `/lantern help` for the list.", sub),
+ ))
+ }
+}
+
+// slackReply wraps a plain-text body in Slack's standard ephemeral
+// response shape so only the invoking user sees it.
+func slackReply(text string) map[string]any {
+ return map[string]any{
+ "response_type": "ephemeral",
+ "text": text,
+ }
+}
+
+func slackHelpBlock() string {
+ return strings.Join([]string{
+ ":robot_face: *Lantern commands*",
+ "",
+ "`/lantern status` — control-plane uptime + configured surfaces + recent runs",
+ "`/lantern ping` — quick liveness check",
+ "`/lantern agents` — list active agents in this workspace's tenant",
+ "`/lantern help` — show this message",
+ }, "\n")
+}
+
+// statusReply assembles a compact health summary for the tenant. Each
+// query is independent — a failure on one (DB hiccup, etc.) degrades
+// to "0" rather than aborting the whole reply.
+func (h *SlackCommandHandler) statusReply(ctx context.Context, tenantID string) string {
+ if tenantID == "" {
+ return ":warning: This Slack workspace isn't linked to a Lantern tenant yet. Install the Slack surface in the dashboard first."
+ }
+ var (
+ surfaces int
+ connectors int
+ agents int
+ runs24h int
+ lastRunStartedAt *time.Time
+ )
+ _ = h.srv.Pool.QueryRow(ctx,
+ `SELECT COUNT(*) FROM surface_configs WHERE tenant_id = $1 AND status = 'connected'`,
+ tenantID,
+ ).Scan(&surfaces)
+ _ = h.srv.Pool.QueryRow(ctx,
+ `SELECT COUNT(*) FROM connector_installs WHERE tenant_id = $1 AND status = 'connected'`,
+ tenantID,
+ ).Scan(&connectors)
+ _ = h.srv.Pool.QueryRow(ctx,
+ `SELECT COUNT(*) FROM agents WHERE tenant_id = $1 AND archived_at IS NULL`,
+ tenantID,
+ ).Scan(&agents)
+ _ = h.srv.Pool.QueryRow(ctx,
+ `SELECT COUNT(*), MAX(started_at) FROM runs
+ WHERE tenant_id = $1 AND started_at > now() - interval '24 hours'`,
+ tenantID,
+ ).Scan(&runs24h, &lastRunStartedAt)
+
+ lines := []string{
+ ":green_circle: *Lantern is alive*",
+ fmt.Sprintf("agents: %d · connectors: %d · surfaces: %d", agents, connectors, surfaces),
+ fmt.Sprintf("runs (24h): %d", runs24h),
+ }
+ if lastRunStartedAt != nil {
+ lines = append(lines, fmt.Sprintf("last run: %s ago", humanizeSlackDuration(time.Since(*lastRunStartedAt))))
+ }
+ return strings.Join(lines, "\n")
+}
+
+// agentsReply lists the tenant's active agents with their model.
+func (h *SlackCommandHandler) agentsReply(ctx context.Context, tenantID string) string {
+ if tenantID == "" {
+ return ":warning: This Slack workspace isn't linked to a Lantern tenant yet."
+ }
+ rows, err := h.srv.Pool.Query(ctx,
+ `SELECT name, COALESCE(model, 'auto') FROM agents
+ WHERE tenant_id = $1 AND archived_at IS NULL
+ ORDER BY updated_at DESC NULLS LAST
+ LIMIT 15`,
+ tenantID,
+ )
+ if err != nil {
+ return ":x: Could not list agents."
+ }
+ defer rows.Close()
+
+ lines := []string{":scroll: *Agents*"}
+ count := 0
+ for rows.Next() {
+ var name, model string
+ if err := rows.Scan(&name, &model); err == nil {
+ lines = append(lines, fmt.Sprintf("• `%s` — %s", name, model))
+ count++
+ }
+ }
+ if count == 0 {
+ return "No active agents in this tenant yet."
+ }
+ return strings.Join(lines, "\n")
+}
+
+// resolveTenantFromSlackTeam looks up the tenant that owns this Slack
+// workspace by team_id stored in surface_configs.config. Falls back to
+// the dev tenant when nothing is configured so /lantern works against
+// seeded data without explicit setup.
+func (h *SlackCommandHandler) resolveTenantFromSlackTeam(ctx context.Context, teamID string) string {
+ if teamID == "" {
+ return devTenantID
+ }
+ var tenantID string
+ err := h.srv.Pool.QueryRow(ctx,
+ `SELECT tenant_id::text FROM surface_configs
+ WHERE surface_id = 'slack' AND status = 'connected'
+ AND (config->>'workspaceId' = $1 OR config->>'team_id' = $1)
+ LIMIT 1`,
+ teamID,
+ ).Scan(&tenantID)
+ if err != nil {
+ return devTenantID
+ }
+ return tenantID
+}
+
+// verifySlackSignature implements Slack's signed-request verification.
+// See https://api.slack.com/authentication/verifying-requests-from-slack
+//
+// Rejects requests:
+// - older than 5 minutes (replay protection)
+// - whose computed HMAC doesn't match the X-Slack-Signature header
+func verifySlackSignature(r *http.Request, body []byte, signingSecret string) bool {
+ tsStr := r.Header.Get("X-Slack-Request-Timestamp")
+ sig := r.Header.Get("X-Slack-Signature")
+ if tsStr == "" || sig == "" {
+ return false
+ }
+ ts, err := strconv.ParseInt(tsStr, 10, 64)
+ if err != nil {
+ return false
+ }
+ if time.Now().Unix()-ts > 5*60 {
+ return false
+ }
+ base := fmt.Sprintf("v0:%d:%s", ts, body)
+ mac := hmac.New(sha256.New, []byte(signingSecret))
+ mac.Write([]byte(base))
+ expected := "v0=" + hex.EncodeToString(mac.Sum(nil))
+ return hmac.Equal([]byte(expected), []byte(sig))
+}
+
+// humanizeSlackDuration renders durations as compact, glanceable strings
+// (35s, 7m, 2h, 3d). Distinct from formatUptimeShort in the bridge so
+// the two services can evolve independently.
+func humanizeSlackDuration(d time.Duration) string {
+ s := int(d.Seconds())
+ if s < 60 {
+ return fmt.Sprintf("%ds", s)
+ }
+ m := s / 60
+ if m < 60 {
+ return fmt.Sprintf("%dm", m)
+ }
+ h := m / 60
+ if h < 24 {
+ return fmt.Sprintf("%dh", h)
+ }
+ return fmt.Sprintf("%dd", h/24)
+}
From 329c54c4a667ddd7bdd5142a1cb50a4488397fc5 Mon Sep 17 00:00:00 2001
From: Shekhar Mudarapu
Date: Tue, 19 May 2026 23:29:52 -0400
Subject: [PATCH 003/742] Bridge: pre-warm group sessions on pair (fixes stuck
group decryption)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
User report: after a clean re-pair following the documented phone-side
Linked Device logout, /lantern status worked perfectly in self-chat
but /bot monitor on in a group still got no response. Bridge log
confirmed every group message was failing decryption with:
SessionError: No session record
remoteJid: @g.us
participant: :NN@lid
Root cause: when the bridge pairs as a new linked device, the owner's
phone needs to send a sender-key-distribution-message for each group
the owner is in. WhatsApp sometimes does this automatically when the
phone next sends a group message, but the propagation is unreliable
— in this case the bridge waited 30+ minutes and the key never came.
Fix: explicitly call socket.groupFetchAllParticipating() on
connection.open. This is what other Baileys-based linked-device
clients (whatsmeow, neonize) do; touching the groups list nudges
WhatsApp to push the missing sender keys to this device for every
group at once. Fire-and-forget — failures log a warning but don't
gate connection-open (1-on-1s still work).
Plus a manual escape hatch: new endpoint
POST /session/:tenantId/groups/refresh
that re-runs groupFetchAllParticipating without a full re-pair, in
case the auto-warm on connect didn't take or the user joined a new
group after pairing. Dashboard can wire a "Refresh group sessions"
button to this in a follow-up.
session.ts::refreshGroupSessions(reason)
- New public method. Calls socket.groupFetchAllParticipating() inside
try/catch, returns {ok, count, error?}.
- 'reason' is free-form string for log context (pair / manual / etc.).
- Logs total groups warmed so the diagnostics endpoint can surface it.
session.ts::connection.open handler
- Calls refreshGroupSessions('pair') right after setConnectionState
and before the self-chat confirmation message. void-prefixed so it
doesn't block the rest of the open flow.
index.ts
- New POST /session/:tenantId/groups/refresh route. Returns 404 when
no active session, else the refreshGroupSessions result.
---
services/whatsapp-bridge/src/index.ts | 15 +++++++++
services/whatsapp-bridge/src/session.ts | 45 +++++++++++++++++++++++++
2 files changed, 60 insertions(+)
diff --git a/services/whatsapp-bridge/src/index.ts b/services/whatsapp-bridge/src/index.ts
index dcc54c22..129380f6 100644
--- a/services/whatsapp-bridge/src/index.ts
+++ b/services/whatsapp-bridge/src/index.ts
@@ -236,6 +236,21 @@ app.post("/session/:tenantId/reset", async (req, res) => {
res.json({ status: "reset", message: "Auth credentials wiped. Call /start to issue a fresh QR." });
});
+// POST /session/:tenantId/groups/refresh -- re-fetch metadata for every
+// group the user is in. Triggers WhatsApp to send sender-key
+// distribution messages, which unsticks 'failed to decrypt' loops on
+// groups that were active before the bridge paired. Auto-runs on pair;
+// this endpoint lets the dashboard re-trigger without a full re-pair.
+app.post("/session/:tenantId/groups/refresh", async (req, res) => {
+ const session = sessions.get(req.params.tenantId);
+ if (!session) {
+ res.status(404).json({ error: "No active session for this tenant" });
+ return;
+ }
+ const result = await session.refreshGroupSessions("manual");
+ res.json(result);
+});
+
// GET /session/:tenantId/bot -- current bot mute/pause state
app.get("/session/:tenantId/bot", (req, res) => {
const session = sessions.get(req.params.tenantId);
diff --git a/services/whatsapp-bridge/src/session.ts b/services/whatsapp-bridge/src/session.ts
index 6b340462..c63ca01e 100644
--- a/services/whatsapp-bridge/src/session.ts
+++ b/services/whatsapp-bridge/src/session.ts
@@ -346,6 +346,18 @@ export class WhatsAppSession {
});
this.setConnectionState("connected");
+ // Pre-warm group metadata so group session keys are initialized
+ // BEFORE the first message arrives. Without this, the first
+ // group message after a fresh pair hits libsignal with no
+ // session record → "failed to decrypt message" loop. We have
+ // a real user-reported case where /bot monitor on in a group
+ // sat undelivered for an hour because the group's sender keys
+ // hadn't been bootstrapped on the bridge side. Calling
+ // groupFetchAllParticipating() (fire-and-forget) triggers the
+ // WhatsApp client to send sender-key distribution messages to
+ // this device for every group the user is in.
+ void this.refreshGroupSessions("pair");
+
// Self-chat confirmation. Closes the proof loop — the user
// sees the dashboard go green AND receives a WhatsApp message
// from themselves saying the bridge is alive. On subsequent
@@ -674,6 +686,39 @@ export class WhatsAppSession {
return this.monitoredGroups.has(jid);
}
+ /**
+ * Pre-warm group sessions by fetching metadata for every group the
+ * user is in. Fire-and-forget — failures are logged but never thrown
+ * because the bridge can still function (1-on-1s) without it.
+ *
+ * Why this matters: after a fresh pair, group messages from the
+ * owner's phone fail to decrypt with "SessionError: No session record"
+ * until the phone re-distributes its sender key for each group to the
+ * bridge. groupFetchAllParticipating() touches every group, which
+ * triggers WhatsApp to push the missing keys.
+ *
+ * Reason is a free-form string used purely for log context
+ * ('pair', 'manual', 'on-decrypt-failure' etc.).
+ */
+ async refreshGroupSessions(reason: string): Promise<{ ok: boolean; count: number; error?: string }> {
+ if (!this.socket) {
+ return { ok: false, count: 0, error: "not connected" };
+ }
+ try {
+ // Baileys returns { jid: GroupMetadata } map. We don't need the
+ // metadata itself — just the side effect of the fetch triggering
+ // sender-key distribution.
+ const groups = await this.socket.groupFetchAllParticipating();
+ const count = Object.keys(groups || {}).length;
+ this.logger.info({ count, reason }, "refreshed group sessions");
+ return { ok: true, count };
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ this.logger.warn({ err: msg, reason }, "refreshGroupSessions failed");
+ return { ok: false, count: 0, error: msg };
+ }
+ }
+
/**
* Snapshot of everything the dashboard / commands need to render.
* Expired pauses are filtered out before publishing. The wire format
From a6aad464ff55744fcb89c23d3fa8a0dc17cc3e3e Mon Sep 17 00:00:00 2001
From: Shekhar Mudarapu
Date: Tue, 19 May 2026 23:47:18 -0400
Subject: [PATCH 004/742] WhatsApp: kill spammy 'X's assistant here' disclosure
+ soft identity rule
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
User reported the upfront "Hey, Shekhar's assistant here — I'll keep
things moving until they're back" disclosure DM looked formal, felt
like spam, and scared contacts before they'd even said anything. The
goal was always to make replies feel like a real person; announcing an
assistant up front directly undermined that.
session.ts: stop sending the disclosure by default
- The block that sent the disclosure DM on first contact now only
fires when LANTERN_AGENT_HANDOFF_MESSAGE is explicitly set. Default
is silent — first reply just answers the contact's actual message.
- Kept the env-var escape hatch for users who genuinely WANT to
announce (e.g. corporate accounts where transparency is mandatory).
natural.ts: stronger 'reply AS the owner' default + soft identity rule
- Non-disclosed intro now explicitly says: don't announce you're an
AI/assistant, the contact thinks they're texting the owner and
that should keep being true unless directly questioned.
- New identity rule: when (and only when) the contact directly asks
"is this a bot / is this really you", reply naturally with a casual
acknowledgement — "yeah just helping Shekhar stay on top of things,
they'll see this" — rather than the robotic "I am Shekhar's
assistant". Forbidden words: AI, assistant (use "helper"), bot,
language model.
---
services/whatsapp-bridge/src/natural.ts | 8 ++++++--
services/whatsapp-bridge/src/session.ts | 22 ++++++++++++++--------
2 files changed, 20 insertions(+), 10 deletions(-)
diff --git a/services/whatsapp-bridge/src/natural.ts b/services/whatsapp-bridge/src/natural.ts
index 4700193d..7e8005ea 100644
--- a/services/whatsapp-bridge/src/natural.ts
+++ b/services/whatsapp-bridge/src/natural.ts
@@ -288,11 +288,15 @@ export function agentPersonaPrompt(
const intro = opts.disclosed
? `You are ${ownerName}'s assistant texting on WhatsApp. The contact has already been told you're an assistant covering for ${ownerName}, so don't impersonate them — but match their tone exactly so the thread reads naturally.`
- : `You are texting on WhatsApp AS ${ownerName}. Sound exactly like a real person texting, not like a chatbot.`;
+ : `You are texting on WhatsApp AS ${ownerName}. Sound exactly like a real person texting, not like a chatbot. Default to first person ("I", "me"). Do NOT announce that you're an assistant, AI, or anything unusual — the contact thinks they're texting ${ownerName} and that should keep being true unless they directly question it.`;
+ // Non-disclosed mode now allows a SOFT, casual acknowledgement when the
+ // contact directly asks if it's really ${ownerName} or if it's a bot —
+ // a formal "I am X's assistant" reads like spam; a natural "yeah just
+ // helping out, will loop them in" feels human and honest.
const identityRule = opts.disclosed
? `- You may say "I'm ${ownerName}'s assistant" if asked directly. Otherwise don't bring it up.`
- : `- Never identify yourself as an AI, assistant, bot, or language model.`;
+ : `- Do NOT proactively identify as an AI, assistant, bot, or language model.\n- ONLY if the contact directly asks "is this you / is this a bot / am i talking to ${ownerName}", reply naturally and briefly — e.g. "yeah just helping ${ownerName} stay on top of things, they'll see this", "no it's their helper covering while they're heads-down, will loop them in". Never use the words "AI", "assistant" (use "helper"), "bot", "language model".`;
const lines: string[] = [
intro,
diff --git a/services/whatsapp-bridge/src/session.ts b/services/whatsapp-bridge/src/session.ts
index c63ca01e..361ae122 100644
--- a/services/whatsapp-bridge/src/session.ts
+++ b/services/whatsapp-bridge/src/session.ts
@@ -1288,14 +1288,20 @@ export class WhatsAppSession {
const style = inferStyle(history);
const ownerName =
this.displayName || process.env.LANTERN_OWNER_NAME || OWNER_NAME;
- // One-shot handoff disclosure. The persona below tells the LLM it can
- // identify as an assistant once the contact has been told — without
- // disclosure first, the model would alternately impersonate the owner
- // or apologize for being an AI, neither of which is what we want.
- if (!opts.isGroup && !this.disclosedJids.has(from)) {
- const disclosure =
- process.env.LANTERN_AGENT_HANDOFF_MESSAGE?.trim() ||
- `Hey, ${ownerName}'s assistant here — I'll keep things moving until they're back. They'll see everything we cover.`;
+ // (Removed: the upfront "Hey, X's assistant here — I'll keep things
+ // moving" disclosure that previously fired on first contact. It read
+ // like spam, scared contacts, and broke the natural flow. The persona
+ // prompt below tells the LLM to text AS the owner by default; if a
+ // contact directly asks "is this a bot / are you really X", the
+ // persona allows a casual acknowledgement instead of a formal
+ // announcement. To opt back in to the old behavior, set
+ // LANTERN_AGENT_HANDOFF_MESSAGE — but think hard before doing it.)
+ if (
+ !opts.isGroup &&
+ !this.disclosedJids.has(from) &&
+ process.env.LANTERN_AGENT_HANDOFF_MESSAGE?.trim()
+ ) {
+ const disclosure = process.env.LANTERN_AGENT_HANDOFF_MESSAGE.trim();
try {
await this.sendMessage(from, disclosure);
this.broadcast({
From e8adab5b1e75fd2bc5f9fb4ac4e11e4e23c0189f Mon Sep 17 00:00:00 2001
From: Shekhar Mudarapu
Date: Tue, 19 May 2026 23:51:00 -0400
Subject: [PATCH 005/742] =?UTF-8?q?WhatsApp:=20/lantern=20groups=20command?=
=?UTF-8?q?=20=E2=80=94=20list=20monitored=20groups=20from=20phone?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
User asked 'how will I know in my personal message which groups are
monitored' after typing /bot monitor on in a group didn't get the
expected 👀 reaction (the underlying message never reached the bridge
due to WhatsApp's linked-device sync flakiness at 139 groups). They
need a way to verify monitoring state without leaving WhatsApp.
session.ts
- New /lantern groups self-chat command. Reacts with 👥 + replies:
👥 *N groups monitored*
•
•
• `120363...@g.us` (when name lookup fails)
…and M more (when count > 25)
- Empty state message points at the two ways to opt a group in
(in-group /bot monitor on OR dashboard) so users aren't stuck.
- /lantern help updated to include the new command.
- New resolveGroupName(jid) helper. Calls socket.groupMetadata(jid),
caches the result with a 1h TTL (group names rarely change). Falls
back to raw JID when Baileys can't resolve.
Combined with the curl bypass for opting groups in, the user now has
end-to-end monitoring control from anywhere even when WhatsApp's
device-sync drops their /bot monitor messages: opt in via dashboard
or curl, verify via /lantern groups from their phone.
---
services/whatsapp-bridge/src/session.ts | 56 +++++++++++++++++++++++++
1 file changed, 56 insertions(+)
diff --git a/services/whatsapp-bridge/src/session.ts b/services/whatsapp-bridge/src/session.ts
index 361ae122..f623a010 100644
--- a/services/whatsapp-bridge/src/session.ts
+++ b/services/whatsapp-bridge/src/session.ts
@@ -686,6 +686,36 @@ export class WhatsAppSession {
return this.monitoredGroups.has(jid);
}
+ // jid -> {name, fetchedAt}. Cached so /lantern groups doesn't hit
+ // Baileys' groupMetadata RPC for every entry on every command. TTL is
+ // generous since group names rarely change.
+ private groupNameCache: Map = new Map();
+ private static readonly GROUP_NAME_TTL_MS = 60 * 60_000;
+
+ /**
+ * Best-effort group name lookup. Returns null when Baileys can't reach
+ * WhatsApp, the group metadata isn't cached yet, or the JID isn't a
+ * group. Used by /lantern groups so the listing shows readable names
+ * instead of raw `120363...@g.us` JIDs.
+ */
+ async resolveGroupName(jid: string): Promise {
+ if (!this.isGroupJid(jid) || !this.socket) return null;
+ const cached = this.groupNameCache.get(jid);
+ if (cached && Date.now() - cached.fetchedAt < WhatsAppSession.GROUP_NAME_TTL_MS) {
+ return cached.name;
+ }
+ try {
+ const meta = await this.socket.groupMetadata(jid);
+ const name = (meta?.subject || "").trim();
+ if (!name) return null;
+ this.groupNameCache.set(jid, { name, fetchedAt: Date.now() });
+ return name;
+ } catch (err) {
+ this.logger.debug({ err, jid }, "resolveGroupName failed");
+ return null;
+ }
+ }
+
/**
* Pre-warm group sessions by fetching metadata for every group the
* user is in. Fire-and-forget — failures are logged but never thrown
@@ -1011,6 +1041,7 @@ export class WhatsAppSession {
"",
"`/lantern status` — bridge uptime + agent state",
"`/lantern ping` — quick echo, confirms I'm alive",
+ "`/lantern groups` — list the groups I'm monitoring",
"`/bot off` — silence me everywhere (or in this thread)",
"`/bot on` — un-silence me",
"`/bot status` — show paused contacts + monitored groups",
@@ -1021,6 +1052,31 @@ export class WhatsAppSession {
return;
}
+ // /lantern groups — list monitored groups with friendly names.
+ // Falls back to bare JID when name lookup fails (e.g. metadata not
+ // cached yet). Self-chat only so the list doesn't leak into a friend's
+ // thread.
+ if (trimmed === "/lantern groups" && self) {
+ await this.sendReaction(jid, key, "👥");
+ const monitored = [...this.monitoredGroups];
+ if (monitored.length === 0) {
+ await this.confirmToSelf(
+ "no groups monitored yet.\n\nto opt a group in:\n• type `/bot monitor on` IN the group (most natural), or\n• from the dashboard → Channels → WhatsApp",
+ );
+ return;
+ }
+ const lines: string[] = [`👥 *${monitored.length} group${monitored.length === 1 ? "" : "s"} monitored*`, ""];
+ for (const groupJid of monitored.slice(0, 25)) {
+ const name = await this.resolveGroupName(groupJid);
+ lines.push(name ? `• ${name}` : `• \`${groupJid}\``);
+ }
+ if (monitored.length > 25) {
+ lines.push(`…and ${monitored.length - 25} more`);
+ }
+ await this.confirmToSelf(lines.join("\n"));
+ return;
+ }
+
if (trimmed === "/lantern status") {
const uptimeMs = Date.now() - this.startedAt;
const uptimeStr = formatUptimeShort(uptimeMs);
From 5a1adf6ae6d34d3062fec2e927ae95ce70fd1ffc Mon Sep 17 00:00:00 2001
From: Shekhar Mudarapu
Date: Tue, 19 May 2026 23:58:23 -0400
Subject: [PATCH 006/742] Bridge: persist user prefs outside auth dir + GET
/groups for dashboard
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two user-reported problems with the same root cause: monitoredGroups
was being silently wiped + there was no way to see groups without
curling JIDs.
Root cause: agent_state.json (monitored groups, paused contacts, mute
flag, owner-sent history) lived inside auth_sessions// —
which is exactly the directory `make whatsapp-reset` nukes on every
re-pair. So preferences died alongside auth credentials, with no
visible warning.
session.ts: split auth from preferences
- New bridge_state//agent_state.json (separate from
auth_sessions//). `reset()` no longer touches it.
- One-time migration on next save: if the legacy file exists in
authDir AND the new location is empty, copy it across. Idempotent.
session.ts::listGroups()
- New method. Returns Array<{jid, name, participants, monitored}>
for every group the bridge knows about. Sorted monitored-first,
then alphabetical by name. Reuses groupFetchAllParticipating +
populates the groupNameCache as a side effect.
index.ts
- New GET /session/:tenantId/groups route. Returns {groups: [...]}
shaped exactly how the dashboard's checkbox list needs it. 404
when no active session.
Together: dashboard can now render a real "monitored groups" picker
with names + participant counts (NOT raw JIDs the user has to dig
out). Toggle wires to the existing POST .../bot/group/monitor and
.../bot/group/unmonitor endpoints — no new state plumbing required.
Dashboard UI is the follow-up commit.
---
services/whatsapp-bridge/src/index.ts | 14 +++++++
services/whatsapp-bridge/src/session.ts | 55 ++++++++++++++++++++++++-
2 files changed, 68 insertions(+), 1 deletion(-)
diff --git a/services/whatsapp-bridge/src/index.ts b/services/whatsapp-bridge/src/index.ts
index 129380f6..6ce705c7 100644
--- a/services/whatsapp-bridge/src/index.ts
+++ b/services/whatsapp-bridge/src/index.ts
@@ -251,6 +251,20 @@ app.post("/session/:tenantId/groups/refresh", async (req, res) => {
res.json(result);
});
+// GET /session/:tenantId/groups -- list every group the bridge knows
+// about with {jid, name, participants, monitored}. Powers the dashboard's
+// "monitored groups" checkbox list. Sorted: monitored first, then
+// alphabetical by name.
+app.get("/session/:tenantId/groups", async (req, res) => {
+ const session = sessions.get(req.params.tenantId);
+ if (!session) {
+ res.status(404).json({ error: "No active session for this tenant" });
+ return;
+ }
+ const groups = await session.listGroups();
+ res.json({ groups });
+});
+
// GET /session/:tenantId/bot -- current bot mute/pause state
app.get("/session/:tenantId/bot", (req, res) => {
const session = sessions.get(req.params.tenantId);
diff --git a/services/whatsapp-bridge/src/session.ts b/services/whatsapp-bridge/src/session.ts
index f623a010..a7bd6109 100644
--- a/services/whatsapp-bridge/src/session.ts
+++ b/services/whatsapp-bridge/src/session.ts
@@ -195,7 +195,29 @@ export class WhatsAppSession {
mkdirSync(this.authDir, { recursive: true });
this.agent = new AgentClient(this.logger, this.authDir);
this.attention = new AttentionClassifier(this.logger);
- this.stateFile = join(this.authDir, "agent_state.json");
+ // User preferences (monitored groups, paused contacts, mute) live
+ // OUTSIDE auth_sessions/ so `reset()` (which wipes auth creds for
+ // re-pair) doesn't also nuke the user's settings. Previously they
+ // were colocated and re-pairing silently cleared every preference.
+ const stateDir = join(process.cwd(), "bridge_state", tenantId);
+ mkdirSync(stateDir, { recursive: true });
+ this.stateFile = join(stateDir, "agent_state.json");
+ // One-time migration: if the legacy file exists in authDir but the
+ // new location is empty, move it. Idempotent — runs once per tenant
+ // and then never finds the source file again.
+ const legacyStateFile = join(this.authDir, "agent_state.json");
+ if (existsSync(legacyStateFile) && !existsSync(this.stateFile)) {
+ try {
+ const data = readFileSync(legacyStateFile, "utf8");
+ writeFileSync(this.stateFile, data);
+ this.logger.info(
+ { from: legacyStateFile, to: this.stateFile },
+ "migrated agent_state out of authDir",
+ );
+ } catch (err) {
+ this.logger.warn({ err }, "agent_state migration failed (non-fatal)");
+ }
+ }
this.loadState();
// GC stale bridgeSentIds every minute so a missed echo doesn't leak mem.
this.gcTimer = setInterval(() => this.gcBridgeSentIds(), 60_000);
@@ -716,6 +738,37 @@ export class WhatsAppSession {
}
}
+ /**
+ * Returns every group the bridge knows about with {jid, name,
+ * participantCount, monitored}. Used by the dashboard to render a
+ * checkbox list — no more curl-with-JID workaround for opting groups
+ * in. Sorted: monitored first (so the active ones surface), then
+ * alphabetical by name.
+ */
+ async listGroups(): Promise> {
+ if (!this.socket) return [];
+ let metas: Record = {};
+ try {
+ metas = (await this.socket.groupFetchAllParticipating()) as typeof metas;
+ } catch (err) {
+ this.logger.warn({ err }, "listGroups: groupFetchAllParticipating failed");
+ return [];
+ }
+ const out = Object.values(metas).map((m) => {
+ const jid = m.id || "";
+ const name = (m.subject || "").trim() || jid.split("@")[0];
+ const participants = Array.isArray(m.participants) ? m.participants.length : 0;
+ // Refresh cache while we're at it.
+ if (name && jid) this.groupNameCache.set(jid, { name, fetchedAt: Date.now() });
+ return { jid, name, participants, monitored: this.monitoredGroups.has(jid) };
+ });
+ out.sort((a, b) => {
+ if (a.monitored !== b.monitored) return a.monitored ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ });
+ return out;
+ }
+
/**
* Pre-warm group sessions by fetching metadata for every group the
* user is in. Fire-and-forget — failures are logged but never thrown
From 57cbdb92fac1712d4495d84aeef2fd66a21ffb76 Mon Sep 17 00:00:00 2001
From: Shekhar Mudarapu
Date: Wed, 20 May 2026 00:11:42 -0400
Subject: [PATCH 007/742] WhatsApp: surface every bridge self-reply in
dashboard activity feed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
User report: '/lantern groups' (and any other bridge reply to self-chat)
shows up on the phone as 'Waiting for this message. This may take a
while' — the outbound counterpart of the inbound stale-Signal-keys
issue. The bridge IS sending the reply; the phone just can't decrypt
it. Until now there was no way to see what the bridge actually said.
Fix: confirmToSelf() now broadcasts an agent_reply event with the
full message text BEFORE attempting WhatsApp delivery. The dashboard
WhatsApp Configure modal's activity feed (and the new /personal
activity page) shows the text immediately, regardless of whether the
phone can decrypt it.
Bonus prep for /personal product surface (Option B):
- New lib/bridge-types.ts — extracted ConnectionState / Stage /
BotState / ActivityKind / ActivityEvent / GroupRow / BridgeDiagnostics
out of the 1856-line whatsapp-pairing.tsx so the new /personal/*
sub-pages can consume the same shapes without dragging the whole
component along. whatsapp-pairing.tsx and the new pages will re-export
these from here. No behavior change yet — pure type extraction.
- Sidebar: new "Personal" entry between Activity and Channels with the
Smartphone icon. Routes to /personal (page comes in the next commit).
---
apps/web/components/sidebar.tsx | 13 +++
apps/web/lib/bridge-types.ts | 104 ++++++++++++++++++++++++
services/whatsapp-bridge/src/session.ts | 15 ++++
3 files changed, 132 insertions(+)
create mode 100644 apps/web/lib/bridge-types.ts
diff --git a/apps/web/components/sidebar.tsx b/apps/web/components/sidebar.tsx
index eb380f1d..5ca0d6ea 100644
--- a/apps/web/components/sidebar.tsx
+++ b/apps/web/components/sidebar.tsx
@@ -41,6 +41,7 @@ import {
Plug,
MessageSquare,
Search,
+ Smartphone,
} from "lucide-react";
import clsx from "clsx";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -251,6 +252,18 @@ export function Sidebar() {
collapsed={collapsed}
active={pathname === "/inbox"}
/>
+ {/* Personal — WhatsApp-personal-assistant mode. Distinct product
+ surface from Agents (scheduled automation) and Channels (agent
+ delivery targets). The mental model: Personal is "you on
+ WhatsApp", with voice/auto-reply/groups/contacts as the
+ tunables. */}
+ = {
+ unknown: "bridge",
+ bridge_offline: "bridge",
+ auth_required: "auth",
+ idle: "auth",
+ starting: "pairing",
+ qr_ready: "pairing",
+ connecting: "pairing",
+ connected: "connected",
+ reconnecting: "connected",
+ logged_out: "pairing",
+ conflict: "pairing",
+ error: "pairing",
+};
+
+export interface BotState {
+ muted: boolean;
+ paused: Record; // jid -> until_ms
+ monitoredGroups?: string[];
+}
+
+// Live activity feed entries — derived from `message`, `agent_reply`,
+// and `activity` WebSocket events. Flattened into one timeline so the
+// user sees the full conversational context.
+export type ActivityKind =
+ | "bot_on"
+ | "bot_off"
+ | "monitor_on"
+ | "monitor_off"
+ | "contact_paused"
+ | "contact_resumed"
+ | "attention_dm"
+ | "agent_skipped"
+ | "message_in"
+ | "agent_reply"
+ | "system";
+
+export interface ActivityEvent {
+ id: string;
+ kind: ActivityKind;
+ summary: string;
+ detail?: string;
+ jid?: string;
+ pushName?: string;
+ timestamp: number;
+}
+
+// One row from GET /session/:tid/groups. Powers the searchable group
+// picker on /personal/groups (and the original modal during the
+// migration period).
+export interface GroupRow {
+ jid: string;
+ name: string;
+ participants: number;
+ monitored: boolean;
+}
+
+// Bridge diagnostics blob — what the bridge reports about itself.
+// Optional fields because the bridge fills them lazily.
+export interface BridgeDiagnostics {
+ tenantId?: string;
+ state?: ConnectionState;
+ paired?: boolean;
+ connected?: boolean;
+ phoneNumber?: string | null;
+ displayName?: string | null;
+ startedAt?: number;
+ uptimeMs?: number;
+ lastError?: string | null;
+ reconnectAttempts?: number;
+ authDirPresent?: boolean;
+}
diff --git a/services/whatsapp-bridge/src/session.ts b/services/whatsapp-bridge/src/session.ts
index a7bd6109..6f144252 100644
--- a/services/whatsapp-bridge/src/session.ts
+++ b/services/whatsapp-bridge/src/session.ts
@@ -1212,6 +1212,21 @@ export class WhatsAppSession {
private async confirmToSelf(text: string) {
const own = this.ownJid();
if (!own || !this.socket) return;
+ // Broadcast EVERY self-reply to the dashboard activity feed BEFORE
+ // attempting WhatsApp delivery — that way if the user's phone can't
+ // decrypt the outbound message (the classic 'Waiting for this
+ // message' stale-Signal-keys issue we keep hitting), they can still
+ // see what the bridge said in /personal/activity. The actual text
+ // isn't lost just because Signal couldn't deliver.
+ this.broadcast({
+ type: "agent_reply",
+ data: {
+ to: own,
+ text,
+ kind: "self_reply",
+ timestamp: Date.now(),
+ },
+ });
try {
const sent = await this.socket.sendMessage(own, { text });
if (sent?.key?.id) this.bridgeSentIds.set(sent.key.id, Date.now());
From c92e22458998bf7f641160726bd8c89166e86a2f Mon Sep 17 00:00:00 2001
From: Shekhar Mudarapu
Date: Wed, 20 May 2026 00:21:46 -0400
Subject: [PATCH 008/742] WhatsApp bridge: Telegram mirror for status replies
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
User said: 'I would like to see actual messages of statuses etc in my
mobile. I wont have access to lantern app in my mobile'. The bridge's
WhatsApp self-replies routinely arrive as 'Waiting for this message'
on the user's phone (stale Signal pre-keys from prior re-pairs — a
WhatsApp protocol issue the bridge can't fix). Result: every /lantern
status / /bot confirmation / pairing notification gets silently lost.
Telegram is the right second channel — bot setup is 30 seconds via
BotFather, no E2E key drama, native mobile push, free. Three-channel
fan-out for every confirmToSelf() now:
1. Dashboard activity feed (broadcast over WS, always)
2. Telegram bot (when LANTERN_OWNER_TELEGRAM_* env vars set)
3. WhatsApp self-chat (best-effort; lossy via Signal)
session.ts::mirrorToTelegram
- Fire-and-forget POST to api.telegram.org/bot/sendMessage.
- parse_mode: Markdown so the *bold* + `code` from /lantern status
renders the same on Telegram as it does on WhatsApp.
- Failures log a warning, never block the WA path, never throw to
the caller. Skipped silently when env vars are unset.
- No control-plane round trip; the bridge talks directly to Telegram
so this still works during a control-plane outage.
.env.example
- New section documenting LANTERN_OWNER_TELEGRAM_BOT_TOKEN +
LANTERN_OWNER_TELEGRAM_CHAT_ID with the 90-second setup recipe
(BotFather → message bot → getUpdates → paste chat id).
User experience after this commit:
set env vars → restart bridge → next bridge reply lands on Telegram
instantly + appears in dashboard + maybe-eventually arrives on
WhatsApp. Status visibility goes from 'sometimes' to 'always'.
---
.env.example | 22 ++++++++
services/whatsapp-bridge/src/session.ts | 70 ++++++++++++++++++++-----
2 files changed, 78 insertions(+), 14 deletions(-)
diff --git a/.env.example b/.env.example
index 19be7d82..f0b60cd7 100644
--- a/.env.example
+++ b/.env.example
@@ -156,6 +156,28 @@
# System-prompt augmentation included in every WhatsApp turn.
#LANTERN_SYSTEM_PROMPT=You are Shekhar's WhatsApp assistant. Be brief.
+# ============================================================================
+# WHATSAPP BRIDGE — TELEGRAM MIRROR (status delivery fallback)
+# ============================================================================
+#
+# When WhatsApp's Signal session is in a stale-key 'Waiting for this
+# message' loop, bridge replies still need to reach you on your phone.
+# Configure a Telegram bot here and every /lantern reply, /bot
+# confirmation, pairing notification, etc. ALSO arrives via Telegram
+# instantly with no E2E key issues.
+#
+# Setup (90 seconds):
+# 1. On Telegram, message @BotFather → /newbot → follow prompts
+# → paste the bot token below.
+# 2. Send your new bot any message ("hi") from your account.
+# 3. Get your chat id: open
+# https://api.telegram.org/bot/getUpdates and look for
+# result[0].message.chat.id (an integer). Paste below.
+#
+# Unset = silently disabled; the bridge still tries WhatsApp only.
+#LANTERN_OWNER_TELEGRAM_BOT_TOKEN=
+#LANTERN_OWNER_TELEGRAM_CHAT_ID=
+
# ============================================================================
# INFRASTRUCTURE (typically set by docker-compose, not .env.local)
# ============================================================================
diff --git a/services/whatsapp-bridge/src/session.ts b/services/whatsapp-bridge/src/session.ts
index 6f144252..09310350 100644
--- a/services/whatsapp-bridge/src/session.ts
+++ b/services/whatsapp-bridge/src/session.ts
@@ -1210,23 +1210,24 @@ export class WhatsAppSession {
}
private async confirmToSelf(text: string) {
- const own = this.ownJid();
- if (!own || !this.socket) return;
- // Broadcast EVERY self-reply to the dashboard activity feed BEFORE
- // attempting WhatsApp delivery — that way if the user's phone can't
- // decrypt the outbound message (the classic 'Waiting for this
- // message' stale-Signal-keys issue we keep hitting), they can still
- // see what the bridge said in /personal/activity. The actual text
- // isn't lost just because Signal couldn't deliver.
+ // Three parallel delivery channels — fire all of them so the user
+ // sees the bridge's response regardless of which one happens to be
+ // working today:
+ // 1. Dashboard activity feed (always — broadcast over the WS)
+ // 2. Telegram bot (when LANTERN_OWNER_TELEGRAM_* env vars are set)
+ // 3. WhatsApp self-chat (best-effort; can silently lose to stale
+ // Signal keys, which is the whole reason we have channels 1 + 2)
+ //
+ // No await between them — each is independent. WhatsApp send still
+ // gets awaited so we can register bridgeSentIds for echo suppression.
this.broadcast({
type: "agent_reply",
- data: {
- to: own,
- text,
- kind: "self_reply",
- timestamp: Date.now(),
- },
+ data: { to: this.ownJid() || "self", text, kind: "self_reply", timestamp: Date.now() },
});
+ void this.mirrorToTelegram(text);
+
+ const own = this.ownJid();
+ if (!own || !this.socket) return;
try {
const sent = await this.socket.sendMessage(own, { text });
if (sent?.key?.id) this.bridgeSentIds.set(sent.key.id, Date.now());
@@ -1235,6 +1236,47 @@ export class WhatsAppSession {
}
}
+ // Telegram fallback for bridge status messages. Reliable delivery on
+ // mobile when WhatsApp's Signal session is in stale-key purgatory
+ // (the 'Waiting for this message' loop). Setup: BotFather → /newbot
+ // → paste the token into LANTERN_OWNER_TELEGRAM_BOT_TOKEN, get your
+ // chat id (any of multiple methods — message @userinfobot or just
+ // hit api.telegram.org/bot/getUpdates after you DM the bot)
+ // → set LANTERN_OWNER_TELEGRAM_CHAT_ID.
+ //
+ // Fire-and-forget: failures log a warning but never block the WA
+ // path or throw to the caller. Skipped silently when env not set so
+ // users who don't want telegram never see noise.
+ private async mirrorToTelegram(text: string): Promise {
+ const token = process.env.LANTERN_OWNER_TELEGRAM_BOT_TOKEN;
+ const chatId = process.env.LANTERN_OWNER_TELEGRAM_CHAT_ID;
+ if (!token || !chatId) return;
+ try {
+ const url = `https://api.telegram.org/bot${token}/sendMessage`;
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ chat_id: chatId,
+ text,
+ // Markdown to match WhatsApp's *bold* / `code` rendering we
+ // use in /lantern status / help replies.
+ parse_mode: "Markdown",
+ disable_web_page_preview: true,
+ }),
+ });
+ if (!res.ok) {
+ const body = await res.text().catch(() => "");
+ this.logger.warn(
+ { status: res.status, body: body.slice(0, 200) },
+ "telegram mirror failed",
+ );
+ }
+ } catch (err) {
+ this.logger.warn({ err }, "telegram mirror request errored");
+ }
+ }
+
private isGroupJid(jid: string) {
return jid.endsWith("@g.us");
}
From a155a23199c19e9973a18b5bbe448cbf36cf7e7b Mon Sep 17 00:00:00 2001
From: Shekhar Mudarapu
Date: Wed, 20 May 2026 00:24:52 -0400
Subject: [PATCH 009/742] =?UTF-8?q?WhatsApp=20bridge:=20email=20mirror=20?=
=?UTF-8?q?=E2=80=94=20universal=20status=20delivery=20for=20mobile?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
User said 'I dont use telegram at all', wants reliable bridge status
on mobile without depending on WhatsApp (which keeps swallowing
self-replies via stale-Signal-keys 'Waiting for this message') and
without opening the Lantern dashboard. Email is the universal answer
— every phone has it, push notifications work, no new accounts needed.
session.ts: confirmToSelf now fires four parallel channels
1. Dashboard activity feed (always)
2. Email (LANTERN_OWNER_EMAIL — uses already-configured Gmail
connector via control-plane)
3. Telegram (LANTERN_OWNER_TELEGRAM_* — opt-in)
4. WhatsApp self-chat (best-effort, lossy)
session.ts::mirrorToEmail
- POSTs to /v1/connectors/gmail/execute?action=send_message via
authedFetch (the bridge's existing auto-relogin path) — so the
bridge doesn't need its own SMTP setup, OAuth credentials live on
the control-plane where they already do.
- Subject is the first line of the reply ('Lantern · 🟢 active'),
body is the full text. Inbox preview is glanceable.
- Throttled: same text within 30s = skip (covers retry storms).
- Skips messages under 8 chars (single emojis from reactions etc.,
not worth an email).
.env.example
- Reorganized the status-delivery section. Email at the top
(recommended/universal), Telegram preserved below.
After this commit, setting LANTERN_OWNER_EMAIL=@gmail.com is
the entire setup. Next bridge reply lands in inbox immediately.
---
.env.example | 16 +++++-
services/whatsapp-bridge/src/session.ts | 68 +++++++++++++++++++++----
2 files changed, 74 insertions(+), 10 deletions(-)
diff --git a/.env.example b/.env.example
index f0b60cd7..2ece1f2b 100644
--- a/.env.example
+++ b/.env.example
@@ -157,9 +157,23 @@
#LANTERN_SYSTEM_PROMPT=You are Shekhar's WhatsApp assistant. Be brief.
# ============================================================================
-# WHATSAPP BRIDGE — TELEGRAM MIRROR (status delivery fallback)
+# WHATSAPP BRIDGE — STATUS DELIVERY FALLBACKS
# ============================================================================
#
+# When WhatsApp's Signal session falls into 'Waiting for this message'
+# limbo, the bridge's /lantern responses + /bot confirmations stop
+# reaching your phone. Configure ONE OR MORE of the channels below and
+# the bridge mirrors every status reply to them. Pick whatever you
+# actually use:
+
+# --- Email mirror (universal, recommended) ---
+# Routes via your already-configured Gmail connector at /connectors.
+# Subject = first line of the reply ("Lantern · 🟢 active"). Throttled
+# to one email per unique-text per 30s and skips short reactions.
+#LANTERN_OWNER_EMAIL=
+
+# --- Telegram bot mirror ---
+#
# When WhatsApp's Signal session is in a stale-key 'Waiting for this
# message' loop, bridge replies still need to reach you on your phone.
# Configure a Telegram bot here and every /lantern reply, /bot
diff --git a/services/whatsapp-bridge/src/session.ts b/services/whatsapp-bridge/src/session.ts
index 09310350..e5e7aade 100644
--- a/services/whatsapp-bridge/src/session.ts
+++ b/services/whatsapp-bridge/src/session.ts
@@ -12,6 +12,7 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "fs";
import type { Logger } from "pino";
import { AgentClient } from "./agent.js";
import { AttentionClassifier } from "./attention.js";
+import { authedFetch } from "./auth.js";
import {
agentPersonaPrompt,
inferStyle,
@@ -1210,20 +1211,22 @@ export class WhatsAppSession {
}
private async confirmToSelf(text: string) {
- // Three parallel delivery channels — fire all of them so the user
- // sees the bridge's response regardless of which one happens to be
- // working today:
- // 1. Dashboard activity feed (always — broadcast over the WS)
- // 2. Telegram bot (when LANTERN_OWNER_TELEGRAM_* env vars are set)
- // 3. WhatsApp self-chat (best-effort; can silently lose to stale
- // Signal keys, which is the whole reason we have channels 1 + 2)
+ // Four parallel delivery channels — fire each so the user sees the
+ // bridge's response on at least one channel that happens to be
+ // working today. They're independent: failures on one don't gate
+ // the others; each is opt-in via its own env vars (except the
+ // always-on dashboard broadcast).
//
- // No await between them — each is independent. WhatsApp send still
- // gets awaited so we can register bridgeSentIds for echo suppression.
+ // 1. Dashboard activity feed (always; broadcast over the WS)
+ // 2. Email (LANTERN_OWNER_EMAIL — universal, push via Mail app)
+ // 3. Telegram bot (LANTERN_OWNER_TELEGRAM_* — when configured)
+ // 4. WhatsApp self-chat (best-effort; lossy via Signal pre-key
+ // drift on the user's phone — channels 1-3 are the safety net)
this.broadcast({
type: "agent_reply",
data: { to: this.ownJid() || "self", text, kind: "self_reply", timestamp: Date.now() },
});
+ void this.mirrorToEmail(text);
void this.mirrorToTelegram(text);
const own = this.ownJid();
@@ -1236,6 +1239,53 @@ export class WhatsAppSession {
}
}
+ // Email fallback for bridge status messages. Universal — every phone
+ // has email + push notifications. Routes through the control-plane's
+ // Gmail connector (re-uses the OAuth credentials the user already
+ // configured at /connectors), so the bridge doesn't need its own SMTP
+ // setup.
+ //
+ // Throttling: we don't want the user's inbox flooded with one email
+ // per /lantern ping reaction. Skip when the SAME text was emailed
+ // within the last 30s (covers the retry-storm case). Also skip very
+ // short messages (single emojis from reactions etc).
+ private lastEmailedAt: Map = new Map();
+ private static readonly EMAIL_DEDUP_MS = 30_000;
+ private static readonly EMAIL_MIN_LEN = 8;
+ private async mirrorToEmail(text: string): Promise {
+ const to = process.env.LANTERN_OWNER_EMAIL;
+ if (!to) return;
+ if (text.length < WhatsAppSession.EMAIL_MIN_LEN) return;
+ const lastAt = this.lastEmailedAt.get(text);
+ if (lastAt && Date.now() - lastAt < WhatsAppSession.EMAIL_DEDUP_MS) return;
+ this.lastEmailedAt.set(text, Date.now());
+
+ // First line of the message becomes the subject so the inbox
+ // preview is glanceable ("Lantern · 🟢 active"). Body keeps the
+ // full text + the same first-line subject for context.
+ const firstLine = text.split("\n", 1)[0].slice(0, 100).trim() || "Lantern status";
+ const subject = `Lantern · ${firstLine}`;
+ try {
+ const res = await authedFetch(
+ `/v1/connectors/gmail/execute?action=send_message`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ to, subject, body: text }),
+ },
+ );
+ if (!res.ok) {
+ const body = await res.text().catch(() => "");
+ this.logger.warn(
+ { status: res.status, body: body.slice(0, 200) },
+ "email mirror failed",
+ );
+ }
+ } catch (err) {
+ this.logger.warn({ err }, "email mirror request errored");
+ }
+ }
+
// Telegram fallback for bridge status messages. Reliable delivery on
// mobile when WhatsApp's Signal session is in stale-key purgatory
// (the 'Waiting for this message' loop). Setup: BotFather → /newbot
From 55953770b9e2e1e4727a2cd39a1c2c7ca99ef420 Mon Sep 17 00:00:00 2001
From: Shekhar Mudarapu
Date: Thu, 21 May 2026 00:32:55 -0400
Subject: [PATCH 010/742] /personal product surface + iMessage bridge +
futuristic UX
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
What ships
----------
• @lantern/bridge-core shared package — natural language command
parser, persona/style helpers, calendar lookup, personal/VIP client,
email mirror, command executor, reaction commands, voice commands,
daily-digest scheduler. Both bridges consume the same source instead
of carrying ~1400 lines of duplicate code.
• iMessage bridge (macOS) — chat.db reader + AppleScript sender,
full feature parity with WhatsApp: commands, natural reply,
escalation detection, quiet hours, VIP draft queueing, attachment
understanding (Whisper + vision LLM), per-handle style learning.
Multi-strategy classic AppleScript send (works on Sonoma+).
• /personal dashboard surface — channel-aware (WhatsApp / iMessage
pill switcher), eight sub-pages (Overview, Pair, Auto-reply, VIPs,
Drafts, Groups/Chats, Paused, Activity). VIPs + Drafts are global
(shared backing tables); per-channel settings live under the channel
switcher. Drafts have channel badges + per-channel filter chips.
• Natural-language commands — "pause for 2 hours", "status", "help",
"what's paused", "lantern, hush" — work in iMessage AND any WhatsApp
1-on-1 thread. Slash commands still work. Auto-resume timer for
time-bounded mutes.
• Reaction commands — react to a bot reply with ⏸/🔇 → pause that
contact, ▶️/🟢 → resume, 📊 → status, ❤️ → mark VIP, 🗑 → forget,
👍/👎 → approve/discard VIP drafts.
• Voice commands — record a voice note starting with "lantern, ..."
in self-chat → Whisper transcribes → parsed + dispatched.
• Daily morning digest — 8am self-chat summary of overnight replies,
paused contacts, pending VIP drafts, escalations, next calendar
event. Per-channel, configurable via LANTERN_DIGEST_HOUR.
• VIP smart-draft fan-out — when a VIP messages and bot drafts a
reply, the user gets pinged on every channel (dashboard WS + email
+ cross-channel self-chat) so drafts never sit silent.
• iOS Shortcuts / Siri — /v1/shortcuts/{pause,resume,status,say}
endpoints with plain-text responses Siri can speak aloud. Full
setup guide at docs/personal/SHORTCUTS.md.
• Prod-grade always-on — LaunchAgent .plist templates + install
script for both bridges. Auto-start at login, auto-restart on
crash, logs to ~/Library/Logs/Lantern/.
Fixes
-----
• Gmail OAuth scope: gmail.labels → gmail.modify (label/skip-inbox
on send_message now actually works).
• Email subject sanitization (strip emoji/markdown) + RFC 2047
encoding so Gmail web view stops mojibake-ing.
• inbox-concierge: filter -from:me -label:lantern + flatten
payload.headers[] so the model sees real From/Subject instead
of bare message IDs.
• []map[string]any vs []any type-assertion bug in formatGmail/
GitHub/Linear/Calendar formatters that was silently dropping
prefetched data in-process.
• Channels-page broken Mobile App QR connector — replaced with
real iMessage connector card.
• Dashboard topbar Search/Avatar cluster: ml-auto so it stays
pinned right on single-segment routes like /personal.
• iMessage AppleScript -1708 fixed by switching to classic
AppleScript dialect (works on Sonoma+).
• iMessage bridge ignoring its own sent messages so chat.db echoes
don't trigger contact pauses.
• iMessage group reply gating: only respond when owner is
@mentioned (parity with WhatsApp groups).
---
.env.example | 49 +
.gitignore | 3 +
Makefile | 6 +
apps/web/app/(dashboard)/layout.tsx | 8 +-
.../(dashboard)/personal/activity/page.tsx | 230 +
.../(dashboard)/personal/auto-reply/page.tsx | 215 +
.../(dashboard)/personal/contacts/page.tsx | 139 +
.../app/(dashboard)/personal/drafts/page.tsx | 544 +++
.../app/(dashboard)/personal/groups/page.tsx | 286 ++
apps/web/app/(dashboard)/personal/layout.tsx | 363 ++
apps/web/app/(dashboard)/personal/page.tsx | 494 +++
.../app/(dashboard)/personal/setup/page.tsx | 524 +++
.../web/app/(dashboard)/personal/vip/page.tsx | 324 ++
apps/web/app/(dashboard)/surfaces/page.tsx | 20 +-
.../components/personal/bridge-context.tsx | 572 +++
apps/web/components/whatsapp-pairing.tsx | 244 +-
apps/web/lib/bridge-client.ts | 226 +
apps/web/lib/bridge-types.ts | 17 +-
apps/web/lib/whatsapp-personal-client.ts | 122 +
docs/personal/SHORTCUTS.md | 92 +
packages/bridge-core/package-lock.json | 160 +
packages/bridge-core/package.json | 27 +
.../bridge-core}/src/agent.ts | 152 +-
packages/bridge-core/src/auth.ts | 82 +
packages/bridge-core/src/calendar.ts | 98 +
packages/bridge-core/src/command-executor.ts | 86 +
packages/bridge-core/src/daily-digest.ts | 216 +
packages/bridge-core/src/email-mirror.ts | 97 +
packages/bridge-core/src/index.ts | 14 +
.../bridge-core}/src/natural.ts | 140 +-
packages/bridge-core/src/nl-commands.ts | 240 ++
packages/bridge-core/src/personal.ts | 241 ++
packages/bridge-core/src/reaction-commands.ts | 124 +
packages/bridge-core/src/voice-commands.ts | 41 +
packages/bridge-core/tsconfig.json | 18 +
packages/cli/internal/cli/dev.go | 23 +
scripts/launchd/README.md | 99 +
.../launchd/dev.lantern.imessage-bridge.plist | 61 +
.../launchd/dev.lantern.whatsapp-bridge.plist | 65 +
scripts/launchd/install.sh | 98 +
services/control-plane/cmd/server/main.go | 22 +
services/control-plane/internal/db/migrate.go | 59 +
.../internal/handlers/connector_executor.go | 146 +-
.../internal/handlers/oauth_providers.go | 11 +-
.../control-plane/internal/handlers/rest.go | 171 +
.../internal/handlers/shortcuts.go | 296 ++
.../internal/handlers/template_prefetch.go | 122 +-
.../internal/handlers/templates.go | 57 +-
.../internal/handlers/whatsapp_personal.go | 389 ++
services/imessage-bridge/package-lock.json | 3708 +++++++++++++++++
services/imessage-bridge/package.json | 31 +
services/imessage-bridge/src/applescript.ts | 165 +
services/imessage-bridge/src/chat-db.ts | 256 ++
services/imessage-bridge/src/index.ts | 287 ++
services/imessage-bridge/src/media.ts | 143 +
services/imessage-bridge/src/session.ts | 877 ++++
services/imessage-bridge/tsconfig.json | 20 +
services/whatsapp-bridge/package-lock.json | 15 +
services/whatsapp-bridge/package.json | 1 +
services/whatsapp-bridge/src/attention.ts | 2 +-
services/whatsapp-bridge/src/auth.ts | 145 -
services/whatsapp-bridge/src/index.ts | 25 +-
services/whatsapp-bridge/src/media.ts | 220 +
services/whatsapp-bridge/src/session.ts | 660 ++-
64 files changed, 13995 insertions(+), 393 deletions(-)
create mode 100644 apps/web/app/(dashboard)/personal/activity/page.tsx
create mode 100644 apps/web/app/(dashboard)/personal/auto-reply/page.tsx
create mode 100644 apps/web/app/(dashboard)/personal/contacts/page.tsx
create mode 100644 apps/web/app/(dashboard)/personal/drafts/page.tsx
create mode 100644 apps/web/app/(dashboard)/personal/groups/page.tsx
create mode 100644 apps/web/app/(dashboard)/personal/layout.tsx
create mode 100644 apps/web/app/(dashboard)/personal/page.tsx
create mode 100644 apps/web/app/(dashboard)/personal/setup/page.tsx
create mode 100644 apps/web/app/(dashboard)/personal/vip/page.tsx
create mode 100644 apps/web/components/personal/bridge-context.tsx
create mode 100644 apps/web/lib/bridge-client.ts
create mode 100644 apps/web/lib/whatsapp-personal-client.ts
create mode 100644 docs/personal/SHORTCUTS.md
create mode 100644 packages/bridge-core/package-lock.json
create mode 100644 packages/bridge-core/package.json
rename {services/whatsapp-bridge => packages/bridge-core}/src/agent.ts (57%)
create mode 100644 packages/bridge-core/src/auth.ts
create mode 100644 packages/bridge-core/src/calendar.ts
create mode 100644 packages/bridge-core/src/command-executor.ts
create mode 100644 packages/bridge-core/src/daily-digest.ts
create mode 100644 packages/bridge-core/src/email-mirror.ts
create mode 100644 packages/bridge-core/src/index.ts
rename {services/whatsapp-bridge => packages/bridge-core}/src/natural.ts (75%)
create mode 100644 packages/bridge-core/src/nl-commands.ts
create mode 100644 packages/bridge-core/src/personal.ts
create mode 100644 packages/bridge-core/src/reaction-commands.ts
create mode 100644 packages/bridge-core/src/voice-commands.ts
create mode 100644 packages/bridge-core/tsconfig.json
create mode 100644 scripts/launchd/README.md
create mode 100644 scripts/launchd/dev.lantern.imessage-bridge.plist
create mode 100644 scripts/launchd/dev.lantern.whatsapp-bridge.plist
create mode 100755 scripts/launchd/install.sh
create mode 100644 services/control-plane/internal/handlers/shortcuts.go
create mode 100644 services/control-plane/internal/handlers/whatsapp_personal.go
create mode 100644 services/imessage-bridge/package-lock.json
create mode 100644 services/imessage-bridge/package.json
create mode 100644 services/imessage-bridge/src/applescript.ts
create mode 100644 services/imessage-bridge/src/chat-db.ts
create mode 100644 services/imessage-bridge/src/index.ts
create mode 100644 services/imessage-bridge/src/media.ts
create mode 100644 services/imessage-bridge/src/session.ts
create mode 100644 services/imessage-bridge/tsconfig.json
delete mode 100644 services/whatsapp-bridge/src/auth.ts
create mode 100644 services/whatsapp-bridge/src/media.ts
diff --git a/.env.example b/.env.example
index 2ece1f2b..5b313d06 100644
--- a/.env.example
+++ b/.env.example
@@ -156,6 +156,28 @@
# System-prompt augmentation included in every WhatsApp turn.
#LANTERN_SYSTEM_PROMPT=You are Shekhar's WhatsApp assistant. Be brief.
+# ============================================================================
+# WHATSAPP BRIDGE — FUTURISTIC FEATURES
+# ============================================================================
+#
+# These tune the "personal assistant" behavior layer on top of the bridge:
+#
+# Quiet hours: skip auto-reply during sleep hours so contacts don't see
+# replies at 3am — biggest bot-tell short of an instant typing indicator.
+# Inbound notifications still arrive on your phone normally. Both hours
+# are 0..23 (24h). Default: 22..07 (10pm–7am). Set BOTH or neither.
+#LANTERN_OWNER_TIMEZONE=America/Los_Angeles
+#LANTERN_QUIET_START=22
+#LANTERN_QUIET_END=7
+#
+# Voice notes: inbound voice gets transcribed via Whisper and treated
+# as text. Requires OPENAI_API_KEY above. (No outbound voice yet —
+# voice cloning needs per-user training; future ENV flag.)
+#
+# Image understanding: inbound images get described via the vision LLM.
+# Routes through /v1/completions so it uses whatever provider keys the
+# tenant has configured. No additional setup beyond the LLM keys.
+
# ============================================================================
# WHATSAPP BRIDGE — STATUS DELIVERY FALLBACKS
# ============================================================================
@@ -192,6 +214,33 @@
#LANTERN_OWNER_TELEGRAM_BOT_TOKEN=
#LANTERN_OWNER_TELEGRAM_CHAT_ID=
+# ============================================================================
+# IMESSAGE BRIDGE (macOS only)
+# ============================================================================
+#
+# Native iMessage bridge — reads ~/Library/Messages/chat.db and sends via
+# AppleScript/JXA on Messages.app. Requires:
+# 1. macOS host signed into iMessage
+# 2. Full Disk Access for the binary that runs `make run-imessage-bridge`
+# (System Settings → Privacy & Security → Full Disk Access)
+# 3. Automation permission for the same binary, with Messages enabled
+# (System Settings → Privacy & Security → Automation → enable Messages)
+#
+# Start with: make run-imessage-bridge
+#
+#LANTERN_IMESSAGE_BRIDGE_PORT=3200
+#LANTERN_IMESSAGE_BRIDGE_BIND=127.0.0.1
+#LANTERN_IMESSAGE_BRIDGE_TOKEN=
+#LANTERN_IMESSAGE_BRIDGE_ORIGIN=http://localhost:3001
+#
+# Your own iMessage handle (phone with +country, or email). The
+# control-plane uses this for send-self delivery (Morning Brief etc.).
+#LANTERN_IMESSAGE_OWNER_HANDLE=+15551234567
+#
+# Optional: separate agent name for iMessage vs WhatsApp so style cues
+# can differ. Falls back to LANTERN_AGENT_NAME, then "imessage-assistant".
+#LANTERN_IMESSAGE_AGENT_NAME=imessage-assistant
+
# ============================================================================
# INFRASTRUCTURE (typically set by docker-compose, not .env.local)
# ============================================================================
diff --git a/.gitignore b/.gitignore
index dd021d51..95cc7136 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,6 +63,9 @@ __pycache__/
# WhatsApp bridge runtime state (contains session keys)
services/whatsapp-bridge/auth_sessions/
+services/whatsapp-bridge/bridge_state/
+services/imessage-bridge/bridge_state/
+services/imessage-bridge/node_modules/
# Local Claude Code overrides
.claude/settings.local.json
diff --git a/Makefile b/Makefile
index cf67ade3..ec5203a9 100644
--- a/Makefile
+++ b/Makefile
@@ -50,6 +50,12 @@ run-whatsapp-bridge: ## Start the WhatsApp bridge service
run-whatsapp: run-whatsapp-bridge ## Alias: start the WhatsApp bridge service
+run-imessage-bridge: ## Start the iMessage bridge (macOS only)
+ @bash scripts/kill-port.sh 3200
+ cd services/imessage-bridge && npm run dev
+
+run-imessage: run-imessage-bridge ## Alias: start the iMessage bridge
+
landing-dev: ## Start the landing page in dev mode
cd apps/landing && npm run dev
diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx
index ef7057b0..23aff9bb 100644
--- a/apps/web/app/(dashboard)/layout.tsx
+++ b/apps/web/app/(dashboard)/layout.tsx
@@ -50,8 +50,12 @@ export default function DashboardLayout({
{/* Left: Breadcrumbs */}
- {/* Right: Search hint, Notifications, User avatar */}
-
+ {/* Right: Search hint, Notifications, User avatar.
+ `ml-auto` keeps this pinned to the right even when
+ Breadcrumbs returns null (single-segment routes like
+ /personal). Without it, `justify-between` with only one
+ flex child collapses the group to the left. */}
+
{/* Search shortcut hint */}
-
-
-
Monitored groups
-
- {groups.length}
-
-
- {groups.length === 0 ? (
-
- Groups are off by default. Send{" "}
- /bot monitor on{" "}
- from your phone inside any group to enable.
-
+ Text yourself questions like "find my I-485 receipt" or "send me my latest pay stub" — the assistant searches your Mac, reads the file, answers in your self-chat, and can attach the file as a reply. Everything runs locally; nothing ships to a server except the relevant snippet for the LLM call.
+
+ The agent can ONLY search + read files inside these folders. Path traversal is blocked at the bridge.
+
+
+
+ {roots.map((r) => (
+
+
+
+
{r.displayPath}
+
{r.path}
+
+
+ {r.exists ? "exists" : "missing"}
+
+
+ ))}
+
+
+ Change via LANTERN_PERSONAL_DOCS_ROOTS in .env.local (colon-separated paths), then restart the bridges.
+
+
+ );
+}
+
+function UsageCard() {
+ const examples = [
+ { q: "find my I-485 receipt", a: "Searches Spotlight for matching files, returns the most recent." },
+ { q: "what's in my I-485 folder?", a: "Lists files in matching folders and summarizes contents." },
+ { q: "send me my latest pay stub", a: "Searches, reads top match, attaches the file in your self-chat." },
+ { q: "what's the date on my biometrics letter?", a: "Reads the matching PDF and answers directly." },
+ ];
+ return (
+
+
+
+
How to use it
+
+
+ Text yourself in WhatsApp self-chat OR iMessage self-chat. The agent only responds for messages sent BY you — never to friends.
+
+
+ {examples.map((e, i) => (
+
+
"{e.q}"
+
→ {e.a}
+
+ ))}
+
+
+ );
+}
+
+function SecurityCard() {
+ const guards = [
+ { icon: Lock, title: "Owner-only", text: "Activates only on messages sent FROM your own Apple ID / WhatsApp number. Friends or group chats can never trigger it, even by quoting your messages." },
+ { icon: ShieldCheck, title: "Path-restricted", text: "Searches + reads + attachments are restricted to the allowed folders above. Path-traversal attempts (../, symlinks) are blocked." },
+ { icon: Eye, title: "Audit log", text: "Every search + read + send is appended to bridge_state//personal-docs.log with timestamp + query + paths." },
+ { icon: AlertTriangle, title: "Content snippet only", text: "The LLM call ships at most a 6KB snippet of the matching file for context. Full file content never leaves your Mac unless you explicitly ask the agent to send the file (which goes back to YOUR self-chat, not anywhere else)." },
+ ];
+ return (
+
+ No activity yet. Text yourself a doc query to start.
+
+ ) : (
+
+ {audit.map((e, i) => (
+
+ {e.ts}{" "}
+ {e.action}{" "}
+ {e.data}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/app/(dashboard)/personal/layout.tsx b/apps/web/app/(dashboard)/personal/layout.tsx
index 8a6ca332..cd3a3ed8 100644
--- a/apps/web/app/(dashboard)/personal/layout.tsx
+++ b/apps/web/app/(dashboard)/personal/layout.tsx
@@ -18,6 +18,7 @@ import {
Activity,
Crown,
FileText,
+ FolderSearch,
MessageCircle,
Smartphone,
UserMinus,
@@ -61,6 +62,7 @@ const NAV: SubNavItem[] = [
{ href: "/personal", label: "Overview", icon: Smartphone, scope: "global" },
{ href: "/personal/vip", label: "VIPs", icon: Crown, scope: "global" },
{ href: "/personal/drafts", label: "Drafts", icon: FileText, scope: "global" },
+ { href: "/personal/docs", label: "Docs", icon: FolderSearch, scope: "global" },
// Channel-specific — different per bridge
{ href: "/personal/setup", label: "Pair", icon: Smartphone, scope: "per-channel" },
{ href: "/personal/auto-reply", label: "Auto-reply", icon: Zap, scope: "per-channel" },
diff --git a/apps/web/components/whatsapp-pairing.tsx b/apps/web/components/whatsapp-pairing.tsx
index 01e5fa73..106b5215 100644
--- a/apps/web/components/whatsapp-pairing.tsx
+++ b/apps/web/components/whatsapp-pairing.tsx
@@ -545,6 +545,12 @@ export function WhatsAppPairing({
"idle"
);
+ // Has-creds probe. When true, the on-disk auth dir already holds a
+ // paired session — the dashboard shows "Reconnect" (idempotent
+ // /start that reuses creds) instead of "Pair with QR" so the user
+ // doesn't have to scan again after every code restart.
+ const [hasCreds, setHasCreds] = useState(null);
+
// Wall-clock ticker so countdowns and "just now" labels update without
// re-mounting children. 500ms is smooth enough; lower has no benefit.
const [now, setNow] = useState(Date.now());
@@ -656,6 +662,24 @@ export function WhatsAppPairing({
setState("idle");
}
refreshDiagnostics();
+
+ // Probe whether the bridge has saved creds on disk. When true,
+ // the "idle" CTA shifts from "Pair with QR" to "Reconnect"
+ // (idempotent /start that reuses existing creds — no scan).
+ try {
+ const credsRes = await fetch(
+ `${bridgeUrl}/session/${tenantId}/has-creds`,
+ { signal: AbortSignal.timeout(3000), headers: authHeaders() }
+ );
+ if (credsRes.ok) {
+ const c = (await credsRes.json()) as { hasCreds?: boolean };
+ setHasCreds(!!c.hasCreds);
+ } else {
+ setHasCreds(false);
+ }
+ } catch {
+ setHasCreds(false);
+ }
} catch {
setState("bridge_offline");
}
@@ -1157,15 +1181,21 @@ export function WhatsAppPairing({
-
+ {hasCreds ? (
+
+ ) : (
+
+ )}
- Pair your WhatsApp to start
+ {hasCreds ? "Reconnect to your paired WhatsApp" : "Pair your WhatsApp to start"}
- Generates a QR code you scan from your phone — same flow as WhatsApp Web.
- Auth stays local in auth_sessions/{tenantId}/.
+ {hasCreds
+ ? <>This Mac is already paired — click to resume the existing session. No QR scan needed. Auth lives in auth_sessions/{tenantId}/.>
+ : <>Generates a QR code you scan from your phone — same flow as WhatsApp Web. Auth stays local in auth_sessions/{tenantId}/.>
+ }
@@ -1173,9 +1203,9 @@ export function WhatsAppPairing({
onClick={startPairing}
variant="primary"
size="md"
- icon={}
+ icon={hasCreds ? : }
>
- Pair with QR code
+ {hasCreds ? "Reconnect" : "Pair with QR code"}
)}
diff --git a/docs/personal/BOT-SETUP.md b/docs/personal/BOT-SETUP.md
new file mode 100644
index 00000000..08c66a5d
--- /dev/null
+++ b/docs/personal/BOT-SETUP.md
@@ -0,0 +1,129 @@
+# Dedicated Bot Accounts — iMessage + WhatsApp
+
+This guide gets Lantern off your **personal** self-chat and onto its own
+"Lantern" contact in both iMessage and WhatsApp. You DM the bot like a
+friend; the bot reads your Mac, runs OCR, executes calendar/note/mail
+actions, and replies — same features as self-chat mode, cleaner UX.
+
+The bridge supports **both** topologies simultaneously. If `LANTERN_IMESSAGE_OWNER_HANDLE` or `LANTERN_WA_OWNER_JID` is set, owner-DMs-bot mode is active; otherwise it falls back to self-chat. You can switch any time by toggling the env var.
+
+---
+
+## 1. iMessage — dedicated bot account
+
+### One-time setup
+
+1. **Create a second Apple ID** (only if you don't already have a spare):
+ - Visit [appleid.apple.com](https://appleid.apple.com/account) → **Create Your Apple ID**
+ - Use an email you control (e.g. `lantern.yourname@icloud.com` — iCloud will generate one for free if you don't have a spare)
+ - **No phone number required** — iMessage works with email-only Apple IDs
+ - Verify the email
+2. **Add the bot Apple ID to Messages.app on this Mac**:
+ - Messages → Settings → iMessage → Accounts → **+** → Sign in with the bot's Apple ID
+ - Enable "Enable Messages in iCloud" so messages persist across reboots
+ - **Recommended**: in the same panel, set the bot ID as the *default* outgoing address — replies will come FROM the bot, not your primary
+3. **Sign out of your primary Apple ID on this Mac (optional but cleanest)** — leave only the bot ID signed in. Otherwise, both accounts share `chat.db` and you'll see the bot's replies echo to your primary account via iCloud sync (the bridge dedupes this, but the cleanest UX is bot-only on this Mac).
+4. **From your phone**, open Messages, create a new chat with the bot's email/number, send "hi". You'll see the bot pick it up on the Mac.
+
+### Wiring the bridge
+
+Add to your shell env (or `~/.lantern/env`):
+
+```bash
+export LANTERN_IMESSAGE_OWNER_HANDLE="+15125551234" # your primary phone
+# OR an email if your primary Apple ID uses one:
+# export LANTERN_IMESSAGE_OWNER_HANDLE="shekhar@icloud.com"
+```
+
+Restart the bridge: `make run-imessage-bridge`.
+
+### Test
+
+From your phone, DM the bot: **"when does my passport expire"** — you should see the same `📁 one sec…` ack and OCR-backed answer.
+
+### Notes
+
+- **Normalization**: phone numbers are matched after stripping spaces, dashes, parens, dots. `+1 (512) 555-1234` and `+15125551234` both match.
+- **Backward compatibility**: if you also self-chat (the old way), that still works as a fallback path.
+- **Security**: only messages whose `row.handle` matches `LANTERN_IMESSAGE_OWNER_HANDLE` trigger personal-docs / commands / actions. A random contact who messages the bot gets the normal auto-reply (or nothing if you mute), never doc access.
+
+---
+
+## 2. WhatsApp — dedicated bot account
+
+### One-time setup
+
+1. **Get a second phone number** (pick cheapest that fits):
+ - **Google Voice** (free, US-only): [voice.google.com](https://voice.google.com) — instant, $0
+ - **Twilio** (~$1/mo): [twilio.com/console/phone-numbers](https://www.twilio.com/console/phone-numbers/search) — programmable, US + intl
+ - **Prepaid SIM** (~$10/mo): any local carrier; needs a spare device or eSIM slot
+2. **Register the number with WhatsApp**:
+ - Install WhatsApp on any phone (or eSIM-capable spare iPhone) and complete onboarding with the new number. Verify via SMS.
+ - For Google Voice: WhatsApp will SMS the verification code to your GV number → forwarded to your inbox/main phone. Enter it in WhatsApp.
+ - Once verified, you can uninstall WhatsApp from the spare device — the registration sticks.
+3. **From your primary WhatsApp**, save the bot number as a contact (e.g. "Lantern") and send a "hi" to initialize the chat.
+
+### Pair the bridge to the bot number
+
+```bash
+# Stop any existing whatsapp-bridge:
+lsof -i :3100 -sTCP:LISTEN -t | xargs -r kill -9
+
+# Clear the old auth state (it's paired to your primary number):
+rm -rf services/whatsapp-bridge/auth_sessions//
+
+# Restart and re-pair to the bot number:
+make run-whatsapp-bridge
+```
+
+Visit [http://localhost:3000/personal/whatsapp](http://localhost:3000/personal/whatsapp) (or wherever your dashboard exposes the pairing UI), scan the QR / enter the pairing code **on the bot's WhatsApp**.
+
+### Wiring the env
+
+```bash
+export LANTERN_WA_OWNER_JID="15125551234@s.whatsapp.net"
+# OR just the digits — the bridge appends "@s.whatsapp.net" automatically:
+# export LANTERN_WA_OWNER_JID="15125551234"
+```
+
+Restart the bridge: `make run-whatsapp-bridge`.
+
+### Test
+
+From your primary WhatsApp, DM the bot: **"status"** → should reply with the bridge status block. Then **"when does my passport expire"** → ack + OCR answer.
+
+### Notes
+
+- **JID format**: WhatsApp uses two JID forms. Phone-format `@s.whatsapp.net` is what you typically set. Newer privacy IDs (`@lid`) also work; the bridge normalizes both.
+- **Group support**: the bot can be added to family groups. Group auto-reply still requires explicit @mention or the existing `/bot monitor on` opt-in.
+- **Backward compatibility**: if `LANTERN_WA_OWNER_JID` is unset, self-chat mode remains the gate.
+
+---
+
+## 3. Quick verify
+
+After setup, send each of these to your bot in iMessage / WhatsApp:
+
+| Message | Expected |
+|---|---|
+| `status` | Bridge state block (uptime, paused count, docs toggle, killswitch) |
+| `help` | Command list |
+| `when does my passport expire` | `📁 one sec…` → answer with date → offer to add calendar reminder |
+| `yes` (after the offer) | `📅 added to calendar — "Passport renewal" → Home · …` |
+| `kill switch on` | `🚨 KILL SWITCH ENGAGED` → bot ignores everything until release |
+| `kill switch off` | `✅ kill switch RELEASED` |
+
+If `status` doesn't reply, the env var likely doesn't match what the bot sees. Check the bridge log:
+
+```bash
+tail -f /tmp/lantern-imessage-bridge.log # iMessage
+tail -f /tmp/lantern-whatsapp-bridge.log # WhatsApp
+```
+
+Look for inbound rows showing the sender's handle/JID — set your env to that exact value (after normalization).
+
+---
+
+## 4. Switching back to self-chat
+
+Just `unset LANTERN_IMESSAGE_OWNER_HANDLE` / `unset LANTERN_WA_OWNER_JID` and restart the bridge. Self-chat mode resumes immediately. No data loss; OCR cache and bot-state files survive the switch.
diff --git a/packages/bridge-core/package-lock.json b/packages/bridge-core/package-lock.json
index ec989bbc..49737445 100644
--- a/packages/bridge-core/package-lock.json
+++ b/packages/bridge-core/package-lock.json
@@ -8,12 +8,197 @@
"name": "@lantern/bridge-core",
"version": "0.1.0",
"dependencies": {
+ "pdf-parse": "^2.4.5",
"pino": "^9.14.0"
},
"devDependencies": {
"typescript": "^5.7.0"
}
},
+ "node_modules/@napi-rs/canvas": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
+ "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
+ "license": "MIT",
+ "workspaces": [
+ "e2e/*"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas-android-arm64": "0.1.80",
+ "@napi-rs/canvas-darwin-arm64": "0.1.80",
+ "@napi-rs/canvas-darwin-x64": "0.1.80",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
+ "@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-arm64-musl": "0.1.80",
+ "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-x64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-x64-musl": "0.1.80",
+ "@napi-rs/canvas-win32-x64-msvc": "0.1.80"
+ }
+ },
+ "node_modules/@napi-rs/canvas-android-arm64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
+ "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
+ "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-x64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
+ "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
+ "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
+ "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
+ "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
+ "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
+ "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
+ "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
+ "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -38,6 +223,38 @@
"node": ">=14.0.0"
}
},
+ "node_modules/pdf-parse": {
+ "version": "2.4.5",
+ "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
+ "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@napi-rs/canvas": "0.1.80",
+ "pdfjs-dist": "5.4.296"
+ },
+ "bin": {
+ "pdf-parse": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": ">=20.16.0 <21 || >=22.3.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/mehmet-kozan"
+ }
+ },
+ "node_modules/pdfjs-dist": {
+ "version": "5.4.296",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
+ "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20.16.0 || >=22.3.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^0.1.80"
+ }
+ },
"node_modules/pino": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
diff --git a/packages/bridge-core/package.json b/packages/bridge-core/package.json
index a39e8f13..8e6dd860 100644
--- a/packages/bridge-core/package.json
+++ b/packages/bridge-core/package.json
@@ -17,9 +17,13 @@
"./reaction-commands": "./src/reaction-commands.ts",
"./voice-commands": "./src/voice-commands.ts",
"./daily-digest": "./src/daily-digest.ts",
- "./offline-monitor": "./src/offline-monitor.ts"
+ "./offline-monitor": "./src/offline-monitor.ts",
+ "./personal-docs": "./src/personal-docs.ts",
+ "./mac-actions": "./src/mac-actions.ts",
+ "./humanize": "./src/humanize.ts"
},
"dependencies": {
+ "pdf-parse": "^2.4.5",
"pino": "^9.14.0"
},
"devDependencies": {
diff --git a/packages/bridge-core/src/command-executor.ts b/packages/bridge-core/src/command-executor.ts
index afaa6554..25f7e680 100644
--- a/packages/bridge-core/src/command-executor.ts
+++ b/packages/bridge-core/src/command-executor.ts
@@ -32,6 +32,12 @@ export interface CommandContext {
listPaused: () => Promise | string;
listChats: () => Promise | string;
resumeAll: () => Promise | void;
+ // Personal-docs toggle. enabled=true allows local-file Q&A in the
+ // owner's self-chat. Default ON. The bridge persists the bool.
+ setDocsEnabled?: (enabled: boolean) => Promise | void;
+ // Master kill switch — when true the bridge MUST refuse to do
+ // anything except listen for the off command. Survives restarts.
+ setKillSwitch?: (engaged: boolean) => Promise | void;
// Channel name shown in human replies ("iMessage" or "WhatsApp").
channelLabel: string;
}
@@ -82,5 +88,28 @@ export async function executeCommand(cmd: ParsedCommand, ctx: CommandContext): P
await ctx.reply(renderHelp());
return;
}
+ case "docs-on": {
+ if (ctx.setDocsEnabled) await ctx.setDocsEnabled(true);
+ await ctx.reply(`${cmd.echo}\nask in your self-chat: "what's my passport number?" / "find my I-485 receipt"`);
+ return;
+ }
+ case "docs-off": {
+ if (ctx.setDocsEnabled) await ctx.setDocsEnabled(false);
+ await ctx.reply(`${cmd.echo}\nfile-search is paused. send "docs on" to re-enable.`);
+ return;
+ }
+ case "killswitch-on": {
+ if (ctx.setKillSwitch) await ctx.setKillSwitch(true);
+ // We still acknowledge this ONE message so the user has a
+ // confirmation the killswitch took effect. After this, no
+ // outbound activity until "kill switch off".
+ await ctx.reply(`${cmd.echo}\nbot will ignore ALL messages until you send: *kill switch off*`);
+ return;
+ }
+ case "killswitch-off": {
+ if (ctx.setKillSwitch) await ctx.setKillSwitch(false);
+ await ctx.reply(`${cmd.echo}\nauto-reply + docs are back online.`);
+ return;
+ }
}
}
diff --git a/packages/bridge-core/src/humanize.ts b/packages/bridge-core/src/humanize.ts
new file mode 100644
index 00000000..bd7f4c3e
--- /dev/null
+++ b/packages/bridge-core/src/humanize.ts
@@ -0,0 +1,259 @@
+// Post-processor for the personal-docs agent's LLM output.
+//
+// Why post-process at all? The system prompt instructs the LLM to
+// (a) emit dates in a friendly format and (b) ALWAYS end with an
+// agentic follow-up when the answer contains an expiry / number /
+// file. LLMs are nondeterministic — they follow the instructions
+// most of the time but skip them ~10-20%. For a Jarvis-grade
+// product, "most of the time" isn't acceptable.
+//
+// This module deterministically:
+// 1. Rewrites ISO / numeric dates into "Sept 14, 2031" style.
+// 2. Detects when the answer has an expiry/deadline/ID/file but
+// no question, and appends a natural follow-up offer.
+//
+// The LLM's session memory still sees the WHOLE post-processed text
+// (we don't replay just the original) so the next turn's "yes"
+// continuation works.
+
+const MONTHS = [
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
+];
+
+const MONTHS_LONG: Record = {
+ january: 0, february: 1, march: 2, april: 3, may: 4, june: 5,
+ july: 6, august: 7, september: 8, october: 9, november: 10, december: 11,
+};
+
+interface DetectedDate {
+ iso: string; // "2031-09-14"
+ friendly: string; // "Sept 14, 2031"
+ daysUntil: number; // negative if past
+}
+
+// Friendly format. Three letters + period for the month so it reads
+// like a person wrote it. "Sep" → "Sept" because Sept is more
+// conversational than the strict abbreviation.
+function friendly(d: Date): string {
+ const m = MONTHS[d.getMonth()];
+ const display = m === "Sep" ? "Sept" : m;
+ return `${display} ${d.getDate()}, ${d.getFullYear()}`;
+}
+
+// Parse common date patterns out of LLM output. The order matters:
+// less ambiguous patterns (ISO with dashes, full month names) are
+// tried first. Returns one detected date per match site so the
+// caller can rewrite the original text.
+export interface ParsedHit {
+ raw: string; // the literal substring as it appears in text
+ date: Date;
+ start: number;
+ end: number;
+}
+
+export function parseDates(text: string): ParsedHit[] {
+ const out: ParsedHit[] = [];
+ const seenSpans: Array<[number, number]> = [];
+
+ const tryAdd = (raw: string, date: Date, start: number, end: number) => {
+ if (isNaN(date.getTime())) return;
+ // No overlap with previously matched span (avoid double-rewriting)
+ for (const [s, e] of seenSpans) {
+ if (start < e && end > s) return;
+ }
+ seenSpans.push([start, end]);
+ out.push({ raw, date, start, end });
+ };
+
+ // ISO yyyy-mm-dd (Spotlight stamps look like this)
+ for (const m of text.matchAll(/\b(\d{4})-(\d{2})-(\d{2})\b/g)) {
+ const y = parseInt(m[1], 10), mo = parseInt(m[2], 10) - 1, d = parseInt(m[3], 10);
+ if (mo >= 0 && mo < 12 && d >= 1 && d <= 31 && y >= 1900 && y <= 2200) {
+ tryAdd(m[0], new Date(y, mo, d), m.index!, m.index! + m[0].length);
+ }
+ }
+ // dd/mm/yyyy — Indian/EU convention, common in Indian passports
+ for (const m of text.matchAll(/\b(\d{1,2})\/(\d{1,2})\/(\d{4})\b/g)) {
+ const a = parseInt(m[1], 10), b = parseInt(m[2], 10), y = parseInt(m[3], 10);
+ // Disambiguation: if first part > 12, it's clearly the day (dd/mm)
+ // If second part > 12, swap (mm/dd)
+ // Otherwise default to dd/mm (passport convention in most non-US docs)
+ let day: number, mo: number;
+ if (a > 12) { day = a; mo = b - 1; }
+ else if (b > 12) { mo = a - 1; day = b; }
+ else { day = a; mo = b - 1; }
+ if (mo >= 0 && mo < 12 && day >= 1 && day <= 31 && y >= 1900 && y <= 2200) {
+ tryAdd(m[0], new Date(y, mo, day), m.index!, m.index! + m[0].length);
+ }
+ }
+ // "Month Day, Year" — already friendly, but parse so we can use
+ // it for follow-up detection / daysUntil math.
+ const monthRe = new RegExp(`\\b(${Object.keys(MONTHS_LONG).join("|")})\\s+(\\d{1,2}),?\\s+(\\d{4})\\b`, "gi");
+ for (const m of text.matchAll(monthRe)) {
+ const mo = MONTHS_LONG[m[1].toLowerCase()];
+ const d = parseInt(m[2], 10), y = parseInt(m[3], 10);
+ if (mo !== undefined && d >= 1 && d <= 31 && y >= 1900 && y <= 2200) {
+ tryAdd(m[0], new Date(y, mo, d), m.index!, m.index! + m[0].length);
+ }
+ }
+ return out.sort((a, b) => a.start - b.start);
+}
+
+// Rewrite numeric/ISO date substrings to friendly form. Leaves
+// already-friendly forms alone.
+export function humanizeDates(text: string): { text: string; primaryDate?: DetectedDate } {
+ const hits = parseDates(text);
+ if (hits.length === 0) return { text };
+ // Rewrite right-to-left so indices stay valid
+ let out = text;
+ const sorted = [...hits].sort((a, b) => b.start - a.start);
+ for (const h of sorted) {
+ // Skip if already in long-month form
+ if (/\b[A-Z][a-z]+\s+\d{1,2},?\s+\d{4}\b/.test(h.raw)) continue;
+ const replacement = friendly(h.date);
+ out = out.slice(0, h.start) + replacement + out.slice(h.end);
+ }
+ // Primary date = the first non-past one we found, or the only one
+ const now = new Date();
+ const future = hits.filter((h) => h.date.getTime() > now.getTime());
+ const primary = future[0] || hits[0];
+ const daysUntil = Math.round((primary.date.getTime() - now.getTime()) / 86400000);
+ return {
+ text: out,
+ primaryDate: { iso: primary.date.toISOString().slice(0, 10), friendly: friendly(primary.date), daysUntil },
+ };
+}
+
+// Detect intent hints in the reply. Used to choose the right
+// follow-up wording.
+function hasNumber(text: string): boolean {
+ // Heuristic: a long alphanumeric token (8+ chars with at least one
+ // digit) that isn't a phone or amount. Catches passport numbers,
+ // license #s, account #s, etc.
+ return /[A-Z0-9-]{8,}/i.test(text) && /\d/.test(text);
+}
+function endsWithQuestion(text: string): boolean {
+ return /\?[\s)*_~"]*$/.test(text.trim());
+}
+function containsRenewable(text: string): boolean {
+ // Words that indicate the date is a renewable thing (worth a
+ // reminder) vs. a historical event (don't offer reminder).
+ return /\b(passport|license|visa|insurance|policy|membership|card|certificate|registration|permit|subscription|renewal|expir|valid until|valid through|due)\b/i.test(text);
+}
+
+// Append a natural agentic follow-up offer when the LLM forgot one.
+// Idempotent: if the text already ends with a question, no change.
+export function ensureFollowUp(text: string, primaryDate?: DetectedDate): string {
+ const trimmed = text.trim();
+ if (!trimmed) return text;
+ if (endsWithQuestion(trimmed)) return text;
+
+ // Date + renewable thing → reminder offer with computed days-out
+ if (primaryDate && primaryDate.daysUntil > 30 && containsRenewable(trimmed)) {
+ // Choose lead time based on how far out. >2 years: 6 months; >6mo: 60 days; closer: 30 days
+ const lead = primaryDate.daysUntil > 730 ? "6 months" : primaryDate.daysUntil > 180 ? "60 days" : "30 days";
+ return `${trimmed.replace(/\.?\s*$/, ".")} want me to add a renewal reminder ${lead} before?`;
+ }
+ // Past expiry → urgent renewal nudge
+ if (primaryDate && primaryDate.daysUntil <= 0 && containsRenewable(trimmed)) {
+ return `${trimmed.replace(/\.?\s*$/, ".")} this is already expired — want me to add a renewal task to your calendar today?`;
+ }
+ // ID/number → note offer
+ if (hasNumber(trimmed) && !primaryDate) {
+ return `${trimmed.replace(/\.?\s*$/, ".")} want me to save this as a note for easy access?`;
+ }
+ // No actionable signal → leave as-is (factual answer doesn't need an offer)
+ return text;
+}
+
+// Single entry point for bridges: friendly dates + guaranteed
+// follow-up when applicable. Returns the rewritten reply.
+export function humanizeReply(text: string): string {
+ const { text: dated, primaryDate } = humanizeDates(text);
+ return ensureFollowUp(dated, primaryDate);
+}
+
+// Pending offer that the bridge can execute deterministically when
+// the user confirms. Solves a real LLM-hallucination bug: even when
+// asked to emit a [CALENDAR:...] marker on "yes", the LLM
+// frequently claims the reminder is "already set" and skips the
+// marker. With this offer cache, the bridge fires the AppleScript
+// itself — no LLM round trip needed for the action.
+export interface PendingOffer {
+ kind: "calendar-reminder" | "save-note";
+ // For calendar reminders: the underlying expiry/event date and
+ // how many days before to schedule the reminder.
+ targetIsoDate?: string; // e.g. "2031-09-14"
+ leadDays?: number; // 30 / 60 / 180
+ title?: string; // "Passport renewal", "Green card renewal"
+ // For note saving: the content to save.
+ noteTitle?: string;
+ noteBody?: string;
+ // When the offer was made (for window-based expiry).
+ issuedAt: number;
+}
+
+// Analyze a reply for what offer was just made. Returns null when
+// no offer is detectable. Mirrors the logic in ensureFollowUp so
+// the bridge can store an executable representation of the offer.
+export function detectOfferInReply(text: string, primaryDate?: DetectedDate): PendingOffer | null {
+ const t = text.trim();
+ // Calendar reminder offer
+ const calRe = /\b(?:add|set)\s+(?:a\s+)?(?:renewal\s+)?(?:reminder|event|calendar)\s+(\d+)\s+(day|days|month|months|week|weeks)\s+before/i;
+ const calMatch = t.match(calRe);
+ if (calMatch && primaryDate) {
+ const n = parseInt(calMatch[1], 10);
+ const unit = calMatch[2].toLowerCase();
+ let leadDays = n;
+ if (unit.startsWith("month")) leadDays = n * 30;
+ else if (unit.startsWith("week")) leadDays = n * 7;
+ // Title: try to extract the doc type ("passport", "license", etc.)
+ const docMatch = t.match(/\b(passport|license|visa|insurance|policy|membership|card|certificate|registration|permit|subscription)\b/i);
+ const docType = docMatch ? docMatch[1].toLowerCase() : "document";
+ const title = `${docType.charAt(0).toUpperCase()}${docType.slice(1)} renewal reminder`;
+ return {
+ kind: "calendar-reminder",
+ targetIsoDate: primaryDate.iso,
+ leadDays,
+ title,
+ issuedAt: Date.now(),
+ };
+ }
+ // Note save offer
+ if (/\b(?:save|store|remember|note)\b.*\b(?:as a note|for easy access|for later)\b/i.test(t)) {
+ // Extract the value to save: usually a long alphanumeric ID
+ // present in the same reply.
+ const idMatch = t.match(/\b([A-Z0-9-]{8,})\b/);
+ if (idMatch) {
+ const docMatch = t.match(/\b(passport|green\s*card|license|visa|insurance|policy|account|id)\b/i);
+ const docType = docMatch ? docMatch[1].toLowerCase().replace(/\s+/g, "-") : "id";
+ return {
+ kind: "save-note",
+ noteTitle: `${docType} number`,
+ noteBody: `${docType}: ${idMatch[1]}\n\n(saved by Lantern on ${new Date().toLocaleString()})`,
+ issuedAt: Date.now(),
+ };
+ }
+ }
+ return null;
+}
+
+// Confirmation detection. When the user replies with one of these
+// after we've issued an offer, execute the cached offer instead of
+// passing through to the LLM.
+export function looksLikeConfirmation(text: string): boolean {
+ const t = text.trim().toLowerCase();
+ if (t.length > 40) return false; // too long to be a clean affirmation
+ return /^(yes|yep|yeah|yup|sure|ok|okay|please|do it|go ahead|go for it|set it|add it|save it|set the reminder|add the reminder|schedule it)\b/i.test(t);
+}
+
+// Combined entry point: returns the polished reply AND the offer
+// (if any) so the caller can cache it for next-turn confirmation.
+export function humanizeWithOffer(text: string): { reply: string; offer: PendingOffer | null } {
+ const { text: dated, primaryDate } = humanizeDates(text);
+ const reply = ensureFollowUp(dated, primaryDate);
+ // Detect the offer from the FINAL reply (post-follow-up append).
+ const offer = detectOfferInReply(reply, primaryDate);
+ return { reply, offer };
+}
diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts
index 75f61273..21c7e439 100644
--- a/packages/bridge-core/src/index.ts
+++ b/packages/bridge-core/src/index.ts
@@ -13,3 +13,4 @@ export * from "./reaction-commands.js";
export * from "./voice-commands.js";
export * from "./daily-digest.js";
export * from "./offline-monitor.js";
+export * from "./personal-docs.js";
diff --git a/packages/bridge-core/src/mac-actions.ts b/packages/bridge-core/src/mac-actions.ts
new file mode 100644
index 00000000..6ebdcc68
--- /dev/null
+++ b/packages/bridge-core/src/mac-actions.ts
@@ -0,0 +1,330 @@
+// Native-macOS action layer for Lantern's personal assistant.
+//
+// Wraps AppleScript so the bridge can create Calendar events, Notes,
+// and Mail drafts when the LLM asks for them via [CALENDAR:...] /
+// [NOTE:...] / [MAIL:...] markers. Zero install — uses
+// /usr/bin/osascript and the bundled Calendar.app / Notes.app /
+// Mail.app. The owner controls every action via "Offer + confirm":
+// the LLM suggests an action in conversation, the bridge only fires
+// the action AFTER the user confirms in the next message.
+//
+// Security: AppleScript Automation permission is required (System
+// Settings → Privacy & Security → Automation → enable each target
+// app for the terminal/launchd binary). The bridge degrades
+// gracefully if a perm is missing — surfaces a clear error to the
+// owner so they can grant it.
+
+import { spawn } from "child_process";
+import type { Logger } from "pino";
+
+const OSASCRIPT_TIMEOUT_MS = 12_000;
+
+export interface CalendarEventSpec {
+ title: string;
+ /** ISO 8601 start datetime, e.g. "2026-08-19T09:00:00" (local TZ assumed). */
+ start: string;
+ /** Optional ISO 8601 end. Defaults to start + 30min. */
+ end?: string;
+ /** Optional notes/description for the event. */
+ notes?: string;
+ /** Calendar to add to. Defaults to the user's first writable calendar. */
+ calendarName?: string;
+ /** Reminder minutes BEFORE the event. e.g. [10080, 4320] = 1 week + 3 days. */
+ alarmsMinutesBefore?: number[];
+}
+
+export interface NoteSpec {
+ title: string;
+ body: string;
+ /** Optional folder name. Default: "Notes". */
+ folder?: string;
+}
+
+export interface MailDraftSpec {
+ to: string[];
+ subject: string;
+ body: string;
+ cc?: string[];
+ bcc?: string[];
+}
+
+export type ActionResult = { ok: true; detail?: string } | { ok: false; reason: string };
+
+export class MacActions {
+ private logger: Logger;
+ constructor(logger: Logger) {
+ this.logger = logger.child({ component: "mac-actions" });
+ }
+
+ // ------- Calendar -------
+
+ async createCalendarEvent(spec: CalendarEventSpec): Promise {
+ if (!spec.title || !spec.start) return { ok: false, reason: "title + start required" };
+ const start = parseLocalDate(spec.start);
+ if (!start) return { ok: false, reason: `invalid start date: ${spec.start}` };
+ const end = spec.end ? parseLocalDate(spec.end) : new Date(start.getTime() + 30 * 60_000);
+ if (!end) return { ok: false, reason: `invalid end date: ${spec.end}` };
+
+ const aplStr = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r/g, "").replace(/\n/g, "\\n").replace(/\t/g, "\\t")}"`;
+ // Locale-safe AppleScript dates: AppleScript's `date "YYYY-MM-DD..."`
+ // literal is LOCALE-DEPENDENT and silently corrupts in non-en_US
+ // settings (one user got year 12195 from a 2031 input). The robust
+ // form is to construct an empty date and SET each component
+ // numerically — always works regardless of system locale.
+ const dateBuilder = (varName: string, d: Date) => `
+ set ${varName} to current date
+ set year of ${varName} to ${d.getFullYear()}
+ set month of ${varName} to ${d.getMonth() + 1}
+ set day of ${varName} to ${d.getDate()}
+ set hours of ${varName} to ${d.getHours()}
+ set minutes of ${varName} to ${d.getMinutes()}
+ set seconds of ${varName} to ${d.getSeconds()}`;
+ const buildStart = dateBuilder("startDate", start);
+ const buildEnd = dateBuilder("endDate", end);
+ // Calendar selection. Prior bug: `first calendar whose writable
+ // is true` silently picked pseudo-calendars like "Scheduled
+ // Reminders" — AppleScript reported success but the event was
+ // invisible. Now we try in priority order:
+ // 1. spec.calendarName (explicit request from LLM)
+ // 2. LANTERN_DEFAULT_CALENDAR env
+ // 3. Common defaults: "Home", "Calendar", "Personal", "Work"
+ // and fall back to any writable calendar excluding the known
+ // silent ones.
+ const envDefault = (process.env.LANTERN_DEFAULT_CALENDAR || "").trim();
+ const tryNames = [
+ ...(spec.calendarName ? [spec.calendarName] : []),
+ ...(envDefault ? [envDefault] : []),
+ "Home", "Calendar", "Personal", "Work",
+ ];
+ const tryNamesAS = tryNames.map(aplStr).join(", ");
+ const notesClause = spec.notes
+ ? `set description of newEvent to ${aplStr(spec.notes)}`
+ : "";
+ const alarms = (spec.alarmsMinutesBefore || []).map((m) => `make new sound alarm at end of newEvent with properties {trigger interval:-${m}}`).join("\n ");
+
+ // Pick a real visible calendar by name (case-insensitive prefix
+ // match against the priority list), excluding obvious silent
+ // pseudo-calendars. As a last resort, the FIRST non-silent
+ // writable calendar. Then verify the event was actually written
+ // by reading its summary back.
+ const script = `
+${buildStart}
+${buildEnd}
+tell application "Calendar"
+ set silentNames to {"Scheduled Reminders", "Siri Suggestions", "Birthdays", "US Holidays"}
+ set candidates to {${tryNamesAS}}
+ set targetCal to missing value
+ -- Try priority names first
+ repeat with want in candidates
+ repeat with c in calendars
+ if (name of c as string) is equal to (want as string) and writable of c then
+ set targetCal to c
+ exit repeat
+ end if
+ end repeat
+ if targetCal is not missing value then exit repeat
+ end repeat
+ -- Fallback: first writable calendar that isn't on the silent list
+ if targetCal is missing value then
+ repeat with c in calendars
+ if writable of c and not (silentNames contains (name of c as string)) then
+ set targetCal to c
+ exit repeat
+ end if
+ end repeat
+ end if
+ if targetCal is missing value then
+ error "no usable writable calendar found"
+ end if
+ tell targetCal
+ set newEvent to make new event with properties {summary:${aplStr(spec.title)}, start date:startDate, end date:endDate}
+ ${notesClause}
+ tell newEvent
+ ${alarms}
+ end tell
+ -- Verify the write persisted by reading the start date back
+ -- (some macOS versions silently drop events written to
+ -- readonly-marked calendars even though they pass the writable
+ -- check). Surfaces date-parsing bugs too — if we wrote year
+ -- 12195 by accident, this catches it.
+ set verifyDate to start date of newEvent as string
+ return (name of targetCal as string) & "|" & verifyDate
+ end tell
+end tell`;
+ const res = await this.runOsascript(script);
+ if (!res.ok) return res;
+ const parts = (res.detail || "").split("|");
+ const calName = parts[0] || "Calendar";
+ return { ok: true, detail: `"${spec.title}" → ${calName} · ${formatHuman(start)}` };
+ }
+
+ // ------- Notes -------
+
+ async createNote(spec: NoteSpec): Promise {
+ if (!spec.title) return { ok: false, reason: "title required" };
+ const aplStr = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r/g, "").replace(/\n/g, "\\n").replace(/\t/g, "\\t")}"`;
+ const folderClause = spec.folder
+ ? `set targetFolder to folder ${aplStr(spec.folder)}`
+ : `set targetFolder to default folder`;
+ // Notes.app requires HTML body — convert plain text newlines to .
+ const bodyHtml = `
${escapeHtml(spec.title)}
` + escapeHtml(spec.body || "").replace(/\n/g, " \n");
+ const script = `
+tell application "Notes"
+ ${folderClause}
+ make new note at targetFolder with properties {name:${aplStr(spec.title)}, body:${aplStr(bodyHtml)}}
+end tell`;
+ const res = await this.runOsascript(script);
+ if (!res.ok) return res;
+ return { ok: true, detail: `note "${spec.title}"` };
+ }
+
+ // ------- Mail (draft only — owner reviews + sends manually) -------
+
+ async createMailDraft(spec: MailDraftSpec): Promise {
+ if (!spec.subject || (spec.to?.length ?? 0) === 0) return { ok: false, reason: "subject + at least one recipient required" };
+ const aplStr = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r/g, "").replace(/\n/g, "\\n").replace(/\t/g, "\\t")}"`;
+ const toRecips = spec.to.map((addr) => `make new to recipient at end of to recipients with properties {address:${aplStr(addr)}}`).join("\n ");
+ const ccRecips = (spec.cc || []).map((addr) => `make new cc recipient at end of cc recipients with properties {address:${aplStr(addr)}}`).join("\n ");
+ const bccRecips = (spec.bcc || []).map((addr) => `make new bcc recipient at end of bcc recipients with properties {address:${aplStr(addr)}}`).join("\n ");
+
+ const script = `
+tell application "Mail"
+ set newDraft to make new outgoing message with properties {subject:${aplStr(spec.subject)}, content:${aplStr(spec.body || "")}, visible:true}
+ tell newDraft
+ ${toRecips}
+ ${ccRecips}
+ ${bccRecips}
+ end tell
+ return id of newDraft as string
+end tell`;
+ const res = await this.runOsascript(script);
+ if (!res.ok) return res;
+ return { ok: true, detail: `draft to ${spec.to.join(", ")}: "${spec.subject}"` };
+ }
+
+ // ------- low-level -------
+
+ private runOsascript(script: string): Promise {
+ return new Promise((resolve) => {
+ const proc = spawn("osascript", ["-e", script]);
+ let stdout = "";
+ let stderr = "";
+ const timer = setTimeout(() => {
+ try { proc.kill("SIGKILL"); } catch {}
+ resolve({ ok: false, reason: "osascript timed out (12s)" });
+ }, OSASCRIPT_TIMEOUT_MS);
+ proc.stdout.on("data", (d) => (stdout += d.toString()));
+ proc.stderr.on("data", (d) => (stderr += d.toString()));
+ proc.on("close", (code) => {
+ clearTimeout(timer);
+ if (code === 0) { resolve({ ok: true, detail: stdout.trim() }); return; }
+ const err = (stderr || stdout || `osascript exited ${code}`).trim();
+ // Map common errors to actionable hints.
+ if (err.includes("not authorized") || err.includes("-1743")) {
+ resolve({ ok: false, reason: `Automation permission missing — System Settings → Privacy & Security → Automation → grant access to the relevant app.` });
+ return;
+ }
+ if (err.includes("Application can't be found") || err.includes("Application isn't running") || err.includes("Application isn’t running")) {
+ resolve({ ok: false, reason: `target app isn't installed or hasn't launched. Open it once, then retry.` });
+ return;
+ }
+ this.logger.warn({ err: err.slice(0, 300) }, "osascript failed");
+ resolve({ ok: false, reason: err.slice(0, 300) });
+ });
+ });
+ }
+}
+
+// ---------- helpers ----------
+
+function parseLocalDate(input: string): Date | null {
+ // Accept ISO-with-TZ, ISO-local, "YYYY-MM-DD", "YYYY-MM-DD HH:mm".
+ const trimmed = input.trim();
+ if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
+ return new Date(trimmed + "T09:00:00"); // default 9am for date-only
+ }
+ if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?$/.test(trimmed)) {
+ // No TZ — treat as local
+ return new Date(trimmed.replace(" ", "T"));
+ }
+ const d = new Date(trimmed);
+ return isNaN(d.getTime()) ? null : d;
+}
+
+// AppleScript wants "date \"Monday, August 19, 2026 at 9:00:00 AM\""
+// — but the safer cross-locale form is the constructor:
+// `(current date)` then set its components — see createCalendarEvent's
+// inline `dateBuilder` for the actual implementation.
+
+function escapeHtml(s: string): string {
+ return s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]!));
+}
+
+function formatHuman(d: Date): string {
+ return d.toLocaleString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" });
+}
+
+// ---------- LLM action markers ----------
+//
+// The LLM signals desired actions with markers in its reply. The
+// bridge parses them, strips them from the user-facing text, and
+// invokes the corresponding MacActions method. Marker grammar
+// mirrors [ATTACH:...] for consistency.
+//
+// [CALENDAR:title|start-iso|end-iso?|notes?]
+// [NOTE:title|body]
+// [MAIL:to-addr(s comma-separated)|subject|body]
+//
+// IMPORTANT: per the "Offer + confirm" UX, the LLM is instructed
+// to emit markers ONLY after the owner explicitly confirms the
+// offer. Suggestions in conversational text don't get parsed.
+
+const RE_CALENDAR = /\[CALENDAR:([^\]]+)\]/g;
+const RE_NOTE = /\[NOTE:([^\]]+)\]/g;
+const RE_MAIL = /\[MAIL:([^\]]+)\]/g;
+
+export interface ExtractedActions {
+ cleanedText: string;
+ calendarEvents: CalendarEventSpec[];
+ notes: NoteSpec[];
+ mailDrafts: MailDraftSpec[];
+}
+
+export function extractActionMarkers(text: string): ExtractedActions {
+ let cleaned = text;
+ const calendarEvents: CalendarEventSpec[] = [];
+ const notes: NoteSpec[] = [];
+ const mailDrafts: MailDraftSpec[] = [];
+
+ for (const m of text.matchAll(RE_CALENDAR)) {
+ const parts = m[1].split("|").map((p) => p.trim());
+ if (parts.length >= 2 && parts[0] && parts[1]) {
+ calendarEvents.push({
+ title: parts[0],
+ start: parts[1],
+ end: parts[2] || undefined,
+ notes: parts[3] || undefined,
+ });
+ }
+ cleaned = cleaned.replace(m[0], "");
+ }
+ for (const m of text.matchAll(RE_NOTE)) {
+ const parts = m[1].split("|").map((p) => p.trim());
+ if (parts.length >= 2 && parts[0]) {
+ notes.push({ title: parts[0], body: parts[1] || "" });
+ }
+ cleaned = cleaned.replace(m[0], "");
+ }
+ for (const m of text.matchAll(RE_MAIL)) {
+ const parts = m[1].split("|").map((p) => p.trim());
+ if (parts.length >= 3 && parts[0] && parts[1]) {
+ mailDrafts.push({
+ to: parts[0].split(",").map((s) => s.trim()).filter(Boolean),
+ subject: parts[1],
+ body: parts[2] || "",
+ });
+ }
+ cleaned = cleaned.replace(m[0], "");
+ }
+ return { cleanedText: cleaned.replace(/\n{3,}/g, "\n\n").trim(), calendarEvents, notes, mailDrafts };
+}
diff --git a/packages/bridge-core/src/nl-commands.ts b/packages/bridge-core/src/nl-commands.ts
index 3bc843e2..43c268bc 100644
--- a/packages/bridge-core/src/nl-commands.ts
+++ b/packages/bridge-core/src/nl-commands.ts
@@ -28,14 +28,18 @@
// bridge schedules an auto-unmute timer.
export type NLCommandAction =
- | "mute" // pause auto-reply globally
- | "unmute" // resume auto-reply
- | "status" // print bridge + bot state
- | "list-paused" // show paused contacts
- | "list-chats" // show monitored chats/groups
- | "resume-all" // clear all per-contact pauses
- | "ping" // liveness pong
- | "help"; // show available commands
+ | "mute" // pause auto-reply globally
+ | "unmute" // resume auto-reply
+ | "status" // print bridge + bot state
+ | "list-paused" // show paused contacts
+ | "list-chats" // show monitored chats/groups
+ | "resume-all" // clear all per-contact pauses
+ | "ping" // liveness pong
+ | "help" // show available commands
+ | "docs-on" // enable personal-docs Q&A
+ | "docs-off" // disable personal-docs Q&A
+ | "killswitch-on" // master kill — disable ALL bot activity
+ | "killswitch-off"; // restore from killswitch
export interface ParsedCommand {
action: NLCommandAction;
@@ -62,6 +66,17 @@ const RESUME_ALL_VERBS = /\bresume\s+(?:all|everyone|everybody)\b/i;
const LIST_PAUSED_VERBS = /\b(?:what'?s\s+paused|who'?s\s+paused|list\s+pa(used|usee)?|paused\s+contacts?|show\s+paused)\b/i;
const LIST_CHATS_VERBS = /\b(?:list\s+(chats|groups)|monitored\s+(chats|groups)|show\s+chats|show\s+groups)\b/i;
const HELP_VERBS = /\b(?:help|what\s+can\s+you\s+do|commands?|usage)\b/i;
+// Personal-docs toggle. Strict prefix so plain chat ("doc this") doesn't
+// misfire. Either "docs on/off" or "personal docs on/off".
+const DOCS_ON_VERBS = /^(?:personal\s+)?docs?\s+(?:on|enable|enabled|allow)$/i;
+const DOCS_OFF_VERBS = /^(?:personal\s+)?docs?\s+(?:off|disable|disabled|deny)$/i;
+// Kill switch — master OFF for the entire bot. Both bridges check this
+// before doing anything else. Recognized forms: "killswitch on",
+// "kill switch off", "kill all", "lantern off", "shut down".
+const KILLSWITCH_ON_VERBS =
+ /^(?:kill\s*switch\s+on|kill\s+(?:all|everything|the\s+bot)|emergency\s+stop|shut\s*down|panic)$/i;
+const KILLSWITCH_OFF_VERBS =
+ /^(?:kill\s*switch\s+off|undo\s+kill|bring\s+(?:it|bot)\s+back|recover|reactivate\s+all)$/i;
// Parse a duration phrase like "for 2 hours", "for 30 min", "until 9am",
// "for tonight", "for an hour". Returns ms, or undefined when none.
@@ -149,6 +164,20 @@ export function parseNLCommand(input: string): ParsedCommand | null {
}
// Order matters — list-paused before mute (the words overlap).
+ // Docs + killswitch use anchored ^...$ regexes so they only match
+ // when the body IS the command, not when it contains the words.
+ if (KILLSWITCH_ON_VERBS.test(body)) {
+ return { action: "killswitch-on", echo: "🚨 kill switch ENGAGED — all bot activity halted", explicit };
+ }
+ if (KILLSWITCH_OFF_VERBS.test(body)) {
+ return { action: "killswitch-off", echo: "✅ kill switch RELEASED — bot resumed", explicit };
+ }
+ if (DOCS_ON_VERBS.test(body)) {
+ return { action: "docs-on", echo: "📁 personal-docs Q&A ENABLED", explicit };
+ }
+ if (DOCS_OFF_VERBS.test(body)) {
+ return { action: "docs-off", echo: "🔒 personal-docs Q&A DISABLED", explicit };
+ }
if (RESUME_ALL_VERBS.test(body)) {
return { action: "resume-all", echo: "resumed all paused contacts", explicit };
}
@@ -212,6 +241,18 @@ function parseSlash(raw: string): ParsedCommand | null {
return { action: "status", echo: "status", explicit };
case "chats": case "groups":
return { action: "list-chats", echo: "listing monitored chats", explicit };
+ case "docs": {
+ const arg = (rest[0] || "").toLowerCase();
+ if (arg === "on" || arg === "enable") return { action: "docs-on", echo: "📁 personal-docs ENABLED", explicit };
+ if (arg === "off" || arg === "disable") return { action: "docs-off", echo: "🔒 personal-docs DISABLED", explicit };
+ return { action: "status", echo: "status (with docs state)", explicit };
+ }
+ case "killswitch": case "kill": case "panic": {
+ const arg = (rest[0] || "").toLowerCase();
+ if (arg === "on" || arg === "engage" || arg === "" ) return { action: "killswitch-on", echo: "🚨 kill switch ENGAGED", explicit };
+ if (arg === "off" || arg === "release") return { action: "killswitch-off", echo: "✅ kill switch RELEASED", explicit };
+ return { action: "status", echo: "status (with killswitch state)", explicit };
+ }
case "help": case "":
return { action: "help", echo: "help", explicit };
}
@@ -225,16 +266,27 @@ export function renderHelp(): string {
return [
"👋 just talk to me naturally — i understand:",
"",
+ "*auto-reply*",
"• *pause* / *mute* / *stop replying* — pause auto-reply",
"• *pause for 2 hours* / *mute until 9am* / *for tonight* — time-bounded pause",
"• *resume* / *wake up* / *unmute* — turn auto-reply back on",
"• *resume everyone* — clear all per-contact pauses",
- "• *status* / *how are you* — current state",
+ "",
+ "*personal docs* (self-chat only — never replies in DMs or groups)",
+ "• *docs on* / *docs off* — toggle local-file Q&A",
+ "• `/lantern docs on|off` — slash form",
+ "",
+ "*safety*",
+ "• *kill switch on* — 🚨 emergency stop (mutes ALL bot activity until released)",
+ "• *kill switch off* — release the kill switch",
+ "• `/lantern killswitch on|off` — slash form",
+ "",
+ "*diagnostics*",
+ "• *status* — current state (incl. docs + killswitch toggles)",
"• *what's paused* — list paused contacts",
"• *list chats* — show monitored group chats",
"• *ping* — liveness check",
"",
"starting messages with *lantern,* is the explicit way (e.g. *lantern, pause for 2h*).",
- "slash commands still work: /bot on, /bot off, /lantern status.",
].join("\n");
}
diff --git a/packages/bridge-core/src/personal-docs.ts b/packages/bridge-core/src/personal-docs.ts
new file mode 100644
index 00000000..f0533242
--- /dev/null
+++ b/packages/bridge-core/src/personal-docs.ts
@@ -0,0 +1,982 @@
+// Personal-docs agent — secure, owner-only Q&A over the user's local
+// files. Runs entirely on the user's Mac (file content never leaves
+// the bridge except as relevant snippets in the LLM prompt).
+//
+// Security model:
+// 1. ONLY fires for owner-sent messages (bridges gate on isFromMe).
+// 2. Paths restricted to LANTERN_PERSONAL_DOCS_ROOTS (default:
+// ~/Documents, ~/Desktop, ~/Library/Mobile Documents/com~apple~CloudDocs).
+// 3. Path-traversal blocked — every read normalizes + checks the
+// resolved path is within an allowed root.
+// 4. Audit log: every search + read + send appended to
+// bridge_state//personal-docs.log with timestamp + query.
+// 5. Owner-only attachment delivery: send_my_file ALWAYS targets the
+// owner's own self-chat, never accepts a contact JID.
+//
+// Search uses macOS Spotlight (`mdfind`) — instant, no indexing
+// overhead, respects the user's existing Spotlight privacy settings.
+// Reading supports PDF / DOCX / TXT / MD / JSON / HTML out of the
+// box; other types return a "binary — won't preview" placeholder.
+
+import { spawn } from "child_process";
+import { existsSync, readFileSync, statSync, appendFileSync, mkdirSync, writeFileSync } from "fs";
+import { resolve, dirname, basename, extname, join, sep } from "path";
+import { homedir } from "os";
+import { createHash } from "crypto";
+import type { Logger } from "pino";
+
+// ---- types ----------------------------------------------------------------
+
+export interface DocSearchResult {
+ path: string; // absolute, normalized
+ displayPath: string; // user-friendly (~/Documents/...)
+ name: string; // basename
+ ext: string; // .pdf / .docx etc.
+ modifiedAt: number; // epoch ms
+ bytes: number;
+ // First ~300 chars from the file (when readable), used by the LLM
+ // to decide which file is the right one without doing a full read.
+ snippet?: string;
+}
+
+export interface DocReadResult {
+ ok: boolean;
+ path: string;
+ displayPath: string;
+ content: string; // first N chars; large files truncated
+ truncated: boolean;
+ bytes: number;
+ ext: string;
+ reason?: string; // populated when ok=false (binary, too large, denied)
+}
+
+export interface PersonalDocsConfig {
+ // Absolute paths the agent is allowed to search/read in.
+ // Defaults to ~/Documents, ~/Desktop, and iCloud Drive root.
+ // Override via LANTERN_PERSONAL_DOCS_ROOTS=path1:path2:path3
+ roots: string[];
+ // Per-search cap (we cap aggressively; LLM context is precious).
+ maxResults: number;
+ // Per-read cap in characters (truncate huge PDFs).
+ maxReadChars: number;
+ // Path to audit log file.
+ auditLogPath: string;
+}
+
+// ---- defaults / config helpers ------------------------------------------
+
+export function defaultPersonalDocsConfig(stateDir: string): PersonalDocsConfig {
+ const home = homedir();
+ const envRoots = (process.env.LANTERN_PERSONAL_DOCS_ROOTS || "")
+ .split(":")
+ .map((r) => r.trim().replace(/^~/, home))
+ .filter(Boolean);
+ const roots = envRoots.length > 0
+ ? envRoots
+ : [
+ join(home, "Documents"),
+ join(home, "Desktop"),
+ join(home, "Library/Mobile Documents/com~apple~CloudDocs"),
+ ];
+ mkdirSync(stateDir, { recursive: true });
+ return {
+ roots,
+ maxResults: parseInt(process.env.LANTERN_PERSONAL_DOCS_MAX_RESULTS || "8", 10),
+ maxReadChars: parseInt(process.env.LANTERN_PERSONAL_DOCS_MAX_CHARS || "12000", 10),
+ auditLogPath: join(stateDir, "personal-docs.log"),
+ };
+}
+
+// Cheap intent classifier — owner-typed messages that match these
+// patterns trigger the personal-docs pipeline. False-positives waste
+// one Spotlight call (~50ms); false-negatives mean the LLM doesn't
+// see the local context. We err toward catching more queries.
+// Doc-noun vocabulary shared by every intent pattern — easier to
+// extend in one place.
+const DOC_NOUN_GROUP = "(?:i-?\\d+|receipt|invoice|bill|statement|tax|pay\\s*stub|w-?2|1099|lease|contract|insurance|passport|visa|license|dl|ssn|social|registration|certificate|diploma|transcript|resume|cv|mortgage|deed|will|policy|prescription|vaccination|vaccine|loan|application|appointment|order|id\\s*card|green\\s*card)";
+// Possessive prefixes: "my", "the", OR any third-party name + "'s"
+// ("Manasa's drivers license", "Ved's passport"). The "\\w+'s" form
+// catches family members, friends, etc.
+const POSSESSIVE = "(?:my|the|\\w+['’]s)";
+
+const DOC_INTENT_PATTERNS: RegExp[] = [
+ // "find my X file" / "show me the X folder" / "where's my X.pdf"
+ /\b(find|search|look for|where('s| is)|locate|show me|pull up)\b.*\b(doc|file|folder|pdf|note|spreadsheet)/i,
+ // "" — "my I-485", "the receipt", "Manasa's license"
+ new RegExp(`\\b${POSSESSIVE}\\s+\\S*\\s*${DOC_NOUN_GROUP}\\b`, "i"),
+ // "what's in my X" — bucket queries
+ /\b(what'?s|whats|what is|tell me about)\b.*\b(in (my|the)|about (my|the)|on (my|the)|from (my|the))\b/i,
+ // "send me the X" / "attach the X" — delivery requests
+ /\b(send me|attach|email me)\b.*\b(my|the|that|it|those|that one|the first|the second)\b/i,
+ // Question intent on : "when does Manasa's license expire"
+ new RegExp(`\\b(what'?s|whats|what is|show me|tell me|where('s| is)|i need|do i have|can you find|can you get|can you pull|when does|when is|when did|when will)\\s+${POSSESSIVE}\\b`, "i"),
+ // " X number/date/expir..." — info lookups
+ new RegExp(`\\b${POSSESSIVE}\\s+\\w+\\s+(number|date|address|amount|deadline|due\\s+date|account|expir(es|ation|y))\\b`, "i"),
+ // iCloud / Mac / drive explicit mentions
+ /\b(icloud|on (my )?(mac|laptop|computer|drive))\b/i,
+ /\bsearch (my )?(mac|laptop|computer|drive)\b/i,
+];
+
+// Short follow-up patterns. These match conversational continuations
+// ("send it", "yes", "the first one", "attach") that wouldn't trigger
+// a fresh search but ARE meaningful as a continuation when the user
+// was just asked about a file. The bridge gates these on "had a
+// recent doc query in this chat" so they don't fire out of context.
+const FOLLOWUP_PATTERNS: RegExp[] = [
+ /^(send|send it|send me|send that|send those|attach|attach it|attach that|email it|email me)\b/i,
+ /^(yes|yep|yeah|yup|sure|ok|okay|please|do it|go ahead)\b/i,
+ /^(the first|the second|the third|first one|second one|that one|this one|both)\b/i,
+];
+
+export function looksLikeDocQuery(text: string): boolean {
+ if (!text || text.length < 2) return false;
+ return DOC_INTENT_PATTERNS.some((re) => re.test(text));
+}
+
+// True for short conversational follow-ups that only make sense
+// when continuing a recent doc-query exchange. The bridge wraps this
+// with a "was there a recent doc query in this chat" gate before
+// dispatching, so a bare "yes" doesn't accidentally trigger the doc
+// pipeline in unrelated conversation.
+export function looksLikeDocFollowup(text: string): boolean {
+ if (!text) return false;
+ const trimmed = text.trim();
+ if (trimmed.length < 2 || trimmed.length > 60) return false;
+ return FOLLOWUP_PATTERNS.some((re) => re.test(trimmed));
+}
+
+// Extract the actual search terms from a natural-language query.
+// "find my I-485 receipt" → "I-485 receipt"
+// "where's my pay stub from last month" → "pay stub last month"
+// "send me the lease" → "lease"
+//
+// Filler words at the start ("find/show/get my", "where is", etc.)
+// would otherwise be passed verbatim to find/mdfind and match
+// nothing.
+const FILLER_PREFIX_RE = /^\s*(?:please\s+)?(?:can you\s+)?(?:hey\s+lantern[,!:\s]+)?(?:lantern[,!:\s]+)?(?:find|search( for)?|look( for)?|locate|show me|pull up|get|grab|fetch|send me|email me|attach|where('s| is|'re| are)|what('s| is)|tell me about)\s+(?:the|my|that|those|some|any|a|an)?\s*/i;
+const FILLER_WORDS = new Set([
+ "from", "the", "my", "that", "this", "these", "those",
+ "a", "an", "on", "in", "of", "to", "and", "or", "for",
+ "please", "thanks", "thank", "you",
+]);
+export function extractSearchTerms(text: string): string {
+ let q = text.trim();
+ // Strip leading filler verbs/articles.
+ q = q.replace(FILLER_PREFIX_RE, "").trim();
+ // Strip trailing punctuation.
+ q = q.replace(/[?.!,;:]+$/g, "").trim();
+ // Drop common stopwords from the remaining tokens, but keep
+ // anything with digits or a dash (I-485, W-2, 1099, 2024) since
+ // those are usually the distinguishing identifier.
+ const tokens = q.split(/\s+/).filter((t) => {
+ if (!t) return false;
+ if (/[\d-]/.test(t)) return true; // keep I-485, W-2, etc.
+ return !FILLER_WORDS.has(t.toLowerCase());
+ });
+ return tokens.join(" ").trim() || text.trim();
+}
+
+// ---- the class ------------------------------------------------------------
+
+export class PersonalDocs {
+ private cfg: PersonalDocsConfig;
+ private logger: Logger;
+
+ constructor(cfg: PersonalDocsConfig, logger: Logger) {
+ this.cfg = cfg;
+ this.logger = logger.child({ component: "personal-docs" });
+ }
+
+ // Returns true if `path` resolves inside one of the configured roots.
+ // Blocks path traversal (../, symlinks pointing outside, etc.).
+ isAllowedPath(path: string): boolean {
+ try {
+ const resolved = resolve(path.replace(/^~/, homedir()));
+ for (const root of this.cfg.roots) {
+ const rootResolved = resolve(root);
+ // Append separator so /tmp/foo doesn't match /tmp/foobar.
+ if (resolved === rootResolved || resolved.startsWith(rootResolved + sep)) {
+ return true;
+ }
+ }
+ } catch {}
+ return false;
+ }
+
+ // Resolve a path the LLM put in an [ATTACH:...] marker to a real
+ // file on disk. The LLM sometimes hallucinates parent directories
+ // — it sees "Shekhar-current-passport-full.pdf" in the context
+ // block and emits "/Users/shakes/Documents/Passport/..." instead
+ // of the real iCloud path. We rescue these cases:
+ // 1. If the literal path exists + is allowed → return it.
+ // 2. Otherwise, search by basename inside the allowed roots and
+ // return the first match that's also allowed.
+ // 3. Failing that, return null and let the caller report the
+ // attach error.
+ // This is purely a usability rescue — the security gate
+ // (isAllowedPath) still has the final say on what we attach.
+ async resolveAttachPath(claimedPath: string): Promise<{ ok: true; path: string; rescued?: boolean } | { ok: false; reason: string }> {
+ const expanded = resolve(claimedPath.replace(/^~/, homedir()));
+ if (existsSync(expanded) && this.isAllowedPath(expanded)) {
+ return { ok: true, path: expanded };
+ }
+ // Try basename search via the existing search() pipeline (mdfind
+ // + find fallback). This will scope to the allowed roots.
+ const base = basename(expanded);
+ if (!base) return { ok: false, reason: `path "${claimedPath}" not found and no basename to rescue` };
+ try {
+ const hits = await this.search(base);
+ for (const h of hits) {
+ if (basename(h.path) === base && this.isAllowedPath(h.path) && existsSync(h.path)) {
+ this.logger.info({ claimed: claimedPath, rescued: h.path }, "ATTACH path rescued by basename search");
+ return { ok: true, path: h.path, rescued: true };
+ }
+ }
+ } catch (err) {
+ this.logger.debug({ err }, "resolveAttachPath: basename search errored");
+ }
+ return { ok: false, reason: `file "${base}" not found in any allowed root` };
+ }
+
+ // Spotlight-backed search. Returns the top N most relevant files
+ // across the configured roots. Falls back to plain `find` when
+ // mdfind is unavailable (rare on macOS).
+ async search(query: string): Promise {
+ // Strip natural-language filler ("find my", "show me the", etc.)
+ // so the underlying mdfind/find search uses just the meaningful
+ // terms. Without this, "find my I-485 receipt" gets passed as
+ // a literal substring match and returns 0.
+ const trimmed = extractSearchTerms(query);
+ if (!trimmed) return [];
+ this.audit("search", { rawQuery: query, terms: trimmed });
+
+ // Try the full term phrase first. If no results, fall back to
+ // the strongest single term — usually the right thing for
+ // "license number" / "ssn last 4" type queries where the noun
+ // matters more than the modifier.
+ const phrases = [trimmed];
+ const tokens = trimmed.split(/\s+/).filter((t) => t.length >= 3);
+ if (tokens.length > 1) {
+ // Add each individual token as a fallback. Prefer
+ // tokens with digits/dashes (I-485, W-2) since those uniquely
+ // identify documents; the rest get added too.
+ const ranked = [...tokens].sort((a, b) => {
+ const aHas = /\d|-/.test(a) ? 1 : 0;
+ const bHas = /\d|-/.test(b) ? 1 : 0;
+ return bHas - aHas;
+ });
+ for (const t of ranked) {
+ if (!phrases.includes(t)) phrases.push(t);
+ }
+ }
+
+ const results: DocSearchResult[] = [];
+ // Pool cap is large — ranker takes over below. The previous
+ // tight cap (maxResults) caused find's length-sorted output to
+ // be truncated to folders + short filenames, hiding the actual
+ // owner's passport (which has a long filename).
+ const POOL_CAP = Math.max(60, this.cfg.maxResults * 6);
+ // Per-phrase per-root: pull a lot so the ranker has a fair pool.
+ const perPhrasePerRoot = 80;
+
+ const seenPaths = new Set();
+ const ingest = (paths: string[], includeFolders: boolean) => {
+ for (const p of paths) {
+ if (seenPaths.has(p)) continue;
+ if (!this.isAllowedPath(p)) continue;
+ if (!existsSync(p)) continue;
+ try {
+ const st = statSync(p);
+ if (st.isDirectory() && !includeFolders) continue;
+ seenPaths.add(p);
+ const ext = st.isDirectory() ? "" : extname(p).toLowerCase();
+ results.push({
+ path: p,
+ displayPath: this.prettyPath(p) + (st.isDirectory() ? "/" : ""),
+ name: basename(p) + (st.isDirectory() ? " (folder)" : ""),
+ ext,
+ modifiedAt: st.mtimeMs,
+ bytes: st.isDirectory() ? 0 : st.size,
+ });
+ if (results.length >= POOL_CAP) return true;
+ } catch {}
+ }
+ return false;
+ };
+
+ // Run BOTH mdfind and find on every root for every phrase, then
+ // dedupe + rank. Why both? Spotlight on iCloud Drive is often
+ // broken ("unknown indexing state") so mdfind silently misses
+ // files. find catches those by walking the tree directly. The
+ // extra cost is a sub-second wait per root (find is fast on
+ // typical doc trees).
+ for (const root of this.cfg.roots) {
+ if (!existsSync(root)) continue;
+ for (const phrase of phrases) {
+ // We deliberately overshoot here — ingest dedupes by path —
+ // because the final ranker re-sorts the union by relevance
+ // score, not by per-source order.
+ try {
+ const paths = await this.mdfind(phrase, root, perPhrasePerRoot);
+ ingest(paths, true);
+ } catch (err) {
+ this.logger.warn({ err, root, phrase }, "mdfind failed");
+ }
+ try {
+ const paths = await this.findByName(phrase, root, perPhrasePerRoot);
+ ingest(paths, true);
+ } catch (err) {
+ this.logger.warn({ err, root, phrase }, "find failed");
+ }
+ if (results.length >= POOL_CAP) break;
+ }
+ }
+
+ // Rank by relevance. The mtime-only sort produced wrong results
+ // (newest family-member passport ranked above the owner's older
+ // current passport). Score components:
+ // - basename matches phrase (any): +30
+ // - path includes owner's first name when query had "my": +20
+ // - is file (not folder): +10
+ // - extension is doc-like: +5
+ // - recency: ((mtime - oldest) / span) * 5 — gentle tie-break
+ // Folder hits stay in the pool (they're useful as breadcrumbs)
+ // but don't beat real files.
+ const ownerFirst = (process.env.LANTERN_OWNER_NAME || "").trim().split(/\s+/)[0]?.toLowerCase() || "";
+ const wantsMine = /\bmy\b|\bmine\b|\bi\b/i.test(query);
+ // Detect a third-party possessive ("Manasa's drivers license",
+ // "Ved's passport") and rank files containing that name higher
+ // than the owner's. The first capture group is the bare name.
+ const possessiveMatch = query.match(/\b([A-Za-z]{3,})['’]s\b/);
+ const targetName = possessiveMatch ? possessiveMatch[1].toLowerCase() : (wantsMine ? ownerFirst : "");
+ const docExts = new Set([".pdf", ".docx", ".doc", ".txt", ".md", ".rtf", ".html", ".csv", ".png", ".jpg", ".jpeg", ".heic"]);
+ const oldest = Math.min(...results.map((r) => r.modifiedAt), Date.now());
+ const newest = Math.max(...results.map((r) => r.modifiedAt), oldest + 1);
+ const span = Math.max(1, newest - oldest);
+ const scoreFor = (r: DocSearchResult): number => {
+ let s = 0;
+ const baseLower = r.name.toLowerCase();
+ const pathLower = r.path.toLowerCase();
+ for (const phrase of phrases) {
+ if (baseLower.includes(phrase.toLowerCase())) { s += 30; break; }
+ }
+ // Person-targeting boost. If the query said "Manasa's …" we
+ // want Manasa's files even though the user (Shekhar) typed.
+ // Penalize the OTHER name to keep cross-talk down (Shekhar's
+ // license file shouldn't beat Manasa's when asked about
+ // Manasa's).
+ if (targetName && pathLower.includes(targetName)) s += 25;
+ if (possessiveMatch && ownerFirst && targetName !== ownerFirst && pathLower.includes(ownerFirst)) s -= 15;
+ if (r.ext && r.ext !== "") s += 10; // not a folder
+ if (r.ext && docExts.has(r.ext)) s += 5; // readable type
+ s += ((r.modifiedAt - oldest) / span) * 5; // recency tie-break
+ return s;
+ };
+ results.sort((a, b) => scoreFor(b) - scoreFor(a) || b.modifiedAt - a.modifiedAt || a.name.localeCompare(b.name));
+ const top = results.slice(0, this.cfg.maxResults);
+
+ // Attach snippets for readable files (best-effort; failures silent).
+ for (const r of top) {
+ try {
+ const head = await this.readHead(r.path, 300);
+ if (head) r.snippet = head;
+ } catch {}
+ }
+ return top;
+ }
+
+ // Read a single file's content. Truncates at maxReadChars. Refuses
+ // anything outside the allowed roots.
+ async read(path: string): Promise {
+ const resolved = resolve(path.replace(/^~/, homedir()));
+ const display = this.prettyPath(resolved);
+ if (!this.isAllowedPath(resolved)) {
+ this.audit("read-denied", { path: resolved });
+ return { ok: false, path: resolved, displayPath: display, content: "", truncated: false, bytes: 0, ext: extname(resolved), reason: "path not in allowed roots" };
+ }
+ if (!existsSync(resolved)) {
+ return { ok: false, path: resolved, displayPath: display, content: "", truncated: false, bytes: 0, ext: extname(resolved), reason: "file not found" };
+ }
+ try {
+ const st = statSync(resolved);
+ if (st.isDirectory()) {
+ return { ok: false, path: resolved, displayPath: display, content: "", truncated: false, bytes: 0, ext: "", reason: "is a directory" };
+ }
+ const ext = extname(resolved).toLowerCase();
+ this.audit("read", { path: resolved, bytes: st.size });
+ const text = await this.extractText(resolved, ext, st.size);
+ if (text.text === null) {
+ return { ok: false, path: resolved, displayPath: display, content: "", truncated: false, bytes: st.size, ext, reason: text.reason || "could not extract text" };
+ }
+ const truncated = text.text.length > this.cfg.maxReadChars;
+ const content = truncated ? text.text.slice(0, this.cfg.maxReadChars) : text.text;
+ return { ok: true, path: resolved, displayPath: display, content, truncated, bytes: st.size, ext };
+ } catch (err) {
+ return { ok: false, path: resolved, displayPath: display, content: "", truncated: false, bytes: 0, ext: extname(resolved), reason: (err as Error).message };
+ }
+ }
+
+ // Build a markdown context block for prompt injection. The bridge
+ // calls this with search results + optionally a few file bodies,
+ // then prepends to the LLM system prompt.
+ async buildContextBlock(query: string, opts: { includeBodies?: boolean } = {}): Promise {
+ const results = await this.search(query);
+ if (results.length === 0) {
+ return `\n\n*Personal docs:* searched for "${query}" — no matching files found in:\n${this.cfg.roots.map((r) => `- ${this.prettyPath(r)}`).join("\n")}`;
+ }
+ const lines: string[] = [];
+ lines.push(`\n\n*Personal docs:* top ${results.length} files matching "${query}":`);
+ for (const r of results) {
+ const ago = humanAgo(Date.now() - r.modifiedAt);
+ const size = humanBytes(r.bytes);
+ lines.push(`- **${r.name}** (${r.ext.replace(".", "") || "file"}, ${size}, modified ${ago})`);
+ lines.push(` \`${r.displayPath}\``);
+ if (r.snippet) lines.push(` > ${r.snippet.replace(/\n/g, " ").slice(0, 220)}…`);
+ }
+
+ // Try reading multiple candidate files until we have enough
+ // content for the LLM to answer. Scanned PDFs / image-only
+ // docs return empty — we skip those and try the next match.
+ // Stop after 3 successful reads or 8000 chars total to stay
+ // within prompt budget.
+ if (opts.includeBodies) {
+ const candidates = results.filter((r) => r.ext && r.ext !== "");
+ // Read top candidates IN PARALLEL. The ranker already put the
+ // most relevant file at index 0; we read the top 3 concurrently
+ // and keep up to MAX_FILES that produced usable text. Parallel
+ // reads turn (3 × ~10s OCR) into (1 × ~10s) wall time, with
+ // the OCR cache making subsequent queries essentially free.
+ const MAX_FILES = 3;
+ const PARALLEL_PROBE = 3;
+ const CHAR_BUDGET = 12000;
+ const PER_FILE_LIMIT = 5000;
+ const probeCount = Math.min(candidates.length, PARALLEL_PROBE);
+ const bodies = await Promise.all(
+ candidates.slice(0, probeCount).map((c) => this.read(c.path).catch(() => null)),
+ );
+ const included: Array<{ display: string; content: string; truncated: boolean }> = [];
+ let totalChars = 0;
+ for (const body of bodies) {
+ if (included.length >= MAX_FILES) break;
+ if (totalChars >= CHAR_BUDGET) break;
+ if (!body || !body.ok) continue;
+ if (body.content.trim().length < 20) continue; // skip empty/garbage
+ const room = CHAR_BUDGET - totalChars;
+ const chunk = body.content.slice(0, Math.min(room, PER_FILE_LIMIT));
+ included.push({ display: body.displayPath, content: chunk, truncated: body.truncated || chunk.length < body.content.length });
+ totalChars += chunk.length;
+ }
+ if (included.length > 0) {
+ lines.push("");
+ lines.push(`*Content from ${included.length} readable file${included.length === 1 ? "" : "s"} (search the answer here first):*`);
+ for (const inc of included) {
+ lines.push("");
+ lines.push(`--- ${inc.display}${inc.truncated ? " (truncated)" : ""} ---`);
+ lines.push("```");
+ lines.push(inc.content);
+ lines.push("```");
+ }
+ } else if (candidates.length > 0) {
+ lines.push("");
+ lines.push(`*(tried ${candidates.length} file${candidates.length === 1 ? "" : "s"} — none had extractable text. May be scanned/image-only PDFs. Offer to attach the file itself.)*`);
+ }
+ }
+
+ // Tell the LLM how to request a file attachment.
+ lines.push("");
+ lines.push(`*If the user wants the file ITSELF, include* \`[ATTACH:]\` *on its own line in your reply. The bridge will strip the marker and send the file as an attachment.*`);
+ return lines.join("\n");
+ }
+
+ // ---- internals ----------------------------------------------------------
+
+ // Fallback when Spotlight isn't indexing the folder. Walks the
+ // filesystem, returning files+folders whose name matches the query
+ // case-insensitively. Pruned to skip hidden/cache dirs that would
+ // make this insanely slow (node_modules, .git, Library/Caches, etc.).
+ private findByName(query: string, root: string, limit: number): Promise {
+ return new Promise((resolve) => {
+ // `find` with -iname "*query*" matches anywhere in the name.
+ // Skip slow/noisy paths to keep traversal under a few hundred ms.
+ const prunePatterns = [
+ "node_modules", ".git", ".next", "dist", "build", ".cache",
+ "Caches", ".Trash", ".DS_Store",
+ ];
+ const pruneArgs: string[] = [];
+ for (const p of prunePatterns) {
+ if (pruneArgs.length) pruneArgs.push("-o");
+ pruneArgs.push("-name", p);
+ }
+ // ( -name X -o -name Y ... ) -prune -o -iname "*query*" -print
+ const escaped = query.replace(/["'`$]/g, "");
+ const args = [
+ root,
+ "(",
+ ...pruneArgs,
+ ")",
+ "-prune",
+ "-o",
+ "-iname",
+ `*${escaped}*`,
+ "-print",
+ ];
+ const proc = spawn("find", args);
+ let stdout = "";
+ const timer = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {}; resolve([]); }, 8000);
+ proc.stdout.on("data", (d) => (stdout += d.toString()));
+ proc.stderr.on("data", () => {}); // swallow "permission denied" noise
+ proc.on("close", () => {
+ clearTimeout(timer);
+ const lines = stdout.split("\n").map((l) => l.trim()).filter((l) => l && l !== root);
+ // No path-length sort here — the caller's relevance ranker
+ // (search()) does the final ordering. Length-sorting would
+ // prefer short folder paths over deeply nested files even
+ // when the file is the better answer.
+ resolve(lines.slice(0, limit));
+ });
+ proc.on("error", () => { clearTimeout(timer); resolve([]); });
+ });
+ }
+
+ private mdfind(query: string, root: string, limit: number): Promise {
+ return new Promise((resolve) => {
+ // -onlyin scopes the search to one root; we union across roots
+ // in `search()`. -name first to also match filename hits, then
+ // fall back to content search via the bare query.
+ const proc = spawn("mdfind", ["-onlyin", root, query]);
+ let stdout = "";
+ const timer = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {}; resolve([]); }, 4000);
+ proc.stdout.on("data", (d) => (stdout += d.toString()));
+ proc.on("close", () => {
+ clearTimeout(timer);
+ const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
+ resolve(lines.slice(0, limit));
+ });
+ proc.on("error", () => { clearTimeout(timer); resolve([]); });
+ });
+ }
+
+ // Best-effort text extraction. PDFs use `mdls` for metadata + `mdimport`-
+ // indexed text via Spotlight's preview. DOCX falls back to `textutil`
+ // (built-in on macOS). TXT/MD/JSON read directly.
+ private async extractText(path: string, ext: string, size: number): Promise<{ text: string | null; reason?: string }> {
+ // Hard cap on size for INLINE-READ paths (txt/json/csv/docx). PDFs
+ // get a higher ceiling because OCR doesn't load the whole file
+ // into memory — it just renders the first N pages (max 1-2MB each
+ // as PNG). A 50MB passport scan or 100MB report is normal and
+ // should still be OCR-able.
+ const inlineLimit = 25 * 1024 * 1024; // 25MB for text-extracted formats
+ const pdfLimit = 200 * 1024 * 1024; // 200MB for PDFs (page-by-page render)
+ const limit = ext === ".pdf" ? pdfLimit : inlineLimit;
+ if (size > limit) {
+ return { text: null, reason: `file is ${humanBytes(size)} — too large` };
+ }
+ const plainExts = new Set([".txt", ".md", ".markdown", ".json", ".csv", ".log", ".html", ".htm", ".xml", ".yaml", ".yml", ".toml", ".rtf"]);
+ if (plainExts.has(ext)) {
+ try {
+ const buf = readFileSync(path, "utf-8");
+ // RTF: strip the rich-text markup for a cleaner read.
+ if (ext === ".rtf") return { text: stripRTF(buf) };
+ return { text: buf };
+ } catch (err) {
+ return { text: null, reason: (err as Error).message };
+ }
+ }
+ // macOS-bundled textutil handles .doc, .docx, .rtf, .html, .webarchive.
+ if ([".doc", ".docx", ".rtf", ".html", ".htm", ".webarchive", ".odt"].includes(ext)) {
+ return { text: await this.textutil(path) };
+ }
+ // PDFs: try four extractors in order of quality:
+ // 1. pdftotext (poppler) — best layout preservation when installed
+ // 2. pdf-parse (pure-Node, bundled) — works for text PDFs
+ // 3. OCR via macOS qlmanage + OpenAI Vision — handles scanned/
+ // image-only PDFs (passport scans, screenshots, etc.) which
+ // have NO embedded text. Skipped when OPENAI_API_KEY isn't
+ // set. ~3-5s for typical first-page OCR.
+ // 4. Spotlight mdls preview — last-resort fallback
+ if (ext === ".pdf") {
+ // For LARGE PDFs (>25MB) skip the in-memory parsers — pdf-parse
+ // and pdftotext both load the whole file and can OOM on a 50MB
+ // scanned passport. Go straight to OCR (renders one page at a
+ // time, peak memory ~ 1-2MB per page PNG).
+ const isLarge = size > 25 * 1024 * 1024;
+ if (!isLarge) {
+ const viaPoppler = await this.pdftotext(path);
+ if (viaPoppler !== null && viaPoppler.trim().length > 50) return { text: viaPoppler };
+ const viaPdfParse = await this.pdfParseNode(path);
+ if (viaPdfParse !== null && viaPdfParse.trim().length > 50) return { text: viaPdfParse };
+ }
+ // Image-only PDF (or large) — OCR via macOS PDFKit + Vision.
+ const viaOcr = await this.ocrViaVision(path);
+ if (viaOcr !== null && viaOcr.trim().length > 0) return { text: `[OCR via Vision LLM]\n${viaOcr}` };
+ const viaSpotlight = await this.spotlightContent(path);
+ if (viaSpotlight !== null) return { text: viaSpotlight };
+ return { text: null, reason: "PDF text extraction failed (OCR also failed — check that an LLM provider is configured in dashboard /settings)" };
+ }
+ // Images: directly OCR via Vision LLM
+ if ([".png", ".jpg", ".jpeg", ".heic", ".gif", ".tiff", ".tif", ".webp"].includes(ext)) {
+ const viaOcr = await this.ocrViaVision(path);
+ if (viaOcr) return { text: `[OCR via Vision LLM]\n${viaOcr}` };
+ return { text: null, reason: "image OCR failed (check that an LLM provider is configured in dashboard /settings)" };
+ }
+ return { text: null, reason: `unsupported file type: ${ext || "unknown"}` };
+ }
+
+ private async readHead(path: string, chars: number): Promise {
+ const ext = extname(path).toLowerCase();
+ if (![".txt", ".md", ".markdown", ".json", ".csv", ".log"].includes(ext)) return "";
+ try {
+ const buf = readFileSync(path, "utf-8");
+ return buf.slice(0, chars).replace(/\s+/g, " ").trim();
+ } catch {
+ return "";
+ }
+ }
+
+ private textutil(path: string): Promise {
+ return new Promise((resolve) => {
+ // textutil -convert txt -stdout reads any supported doc and
+ // writes plain text. No temp file needed.
+ const proc = spawn("textutil", ["-convert", "txt", "-stdout", path]);
+ let stdout = "", stderr = "";
+ const timer = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {}; resolve(null); }, 8000);
+ proc.stdout.on("data", (d) => (stdout += d.toString()));
+ proc.stderr.on("data", (d) => (stderr += d.toString()));
+ proc.on("close", (code) => {
+ clearTimeout(timer);
+ if (code === 0 && stdout.length > 0) resolve(stdout);
+ else { this.logger.debug({ stderr }, "textutil failed"); resolve(null); }
+ });
+ proc.on("error", () => { clearTimeout(timer); resolve(null); });
+ });
+ }
+
+ // OCR fallback using macOS's built-in PDFKit (via JXA) + OpenAI
+ // Vision. Renders up to PERSONAL_DOCS_OCR_MAX_PAGES pages of a PDF
+ // as high-resolution PNGs, OCRs each, concatenates the results.
+ // Critical for scanned/image-only PDFs (passports, receipts) where
+ // the answer often lives on page 2+ (the photo-data page of a
+ // passport, the totals page of a receipt, etc.).
+ //
+ // Why JXA + PDFKit instead of qlmanage? qlmanage -t only renders
+ // the first page. PDFKit (Apple's framework, bundled with macOS)
+ // exposes every page; the JXA bridge lets us drive it without
+ // adding a Python / brew dependency. Zero install.
+ //
+ // Calls go through the bridge's /v1/vision/ocr endpoint which uses
+ // the tenant's configured OpenAI key + gpt-4o-mini vision.
+ // OCR cache directory. Keyed by sha1(path + size + mtime) so a
+ // file that hasn't changed since last OCR returns instantly. First
+ // query: ~5-10s. Cached: <50ms. Lives at ~/.lantern/ocr-cache.
+ private get ocrCacheDir(): string {
+ return join(homedir(), ".lantern", "ocr-cache");
+ }
+ private ocrCacheKey(filePath: string): string | null {
+ try {
+ const st = statSync(filePath);
+ return createHash("sha1").update(`${filePath}|${st.size}|${st.mtimeMs}`).digest("hex");
+ } catch { return null; }
+ }
+ private readOcrCache(filePath: string): string | null {
+ const key = this.ocrCacheKey(filePath);
+ if (!key) return null;
+ const file = join(this.ocrCacheDir, `${key}.txt`);
+ try {
+ if (existsSync(file)) return readFileSync(file, "utf-8");
+ } catch {}
+ return null;
+ }
+ private writeOcrCache(filePath: string, text: string): void {
+ const key = this.ocrCacheKey(filePath);
+ if (!key) return;
+ try {
+ // mode 0o700 on dir, 0o600 on file — OCR'd text can include
+ // passport/license numbers and other PII. Restrict to owner only.
+ mkdirSync(this.ocrCacheDir, { recursive: true, mode: 0o700 });
+ writeFileSync(join(this.ocrCacheDir, `${key}.txt`), text, { mode: 0o600 });
+ } catch (err) {
+ this.logger.debug({ err }, "OCR cache write failed");
+ }
+ }
+
+ private async ocrViaVision(filePath: string): Promise {
+ // Cache hit short-circuit — biggest win for repeat queries.
+ const cached = this.readOcrCache(filePath);
+ if (cached) {
+ this.logger.info({ filePath: basename(filePath) }, "OCR cache hit");
+ return cached;
+ }
+ const ext = extname(filePath).toLowerCase();
+ const tmpDir = `/tmp/lantern-ocr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ let needsCleanup = false;
+ try {
+ let pngs: string[] = [];
+ if (ext === ".pdf") {
+ const maxPages = Number(process.env.LANTERN_PERSONAL_DOCS_OCR_MAX_PAGES || "3");
+ pngs = await this.renderPdfPages(filePath, tmpDir, Math.max(1, maxPages));
+ // Even if renderPdfPages returned 0, the JXA helper may have
+ // created the directory — mark for cleanup either way.
+ needsCleanup = true;
+ if (pngs.length === 0) {
+ const ok = await this.renderPdfPage(filePath, tmpDir);
+ if (!ok) return null;
+ const fs = await import("fs");
+ const entries = fs.readdirSync(tmpDir).filter((f) => f.endsWith(".png"));
+ if (entries.length === 0) return null;
+ pngs = [`${tmpDir}/${entries[0]}`];
+ }
+ } else {
+ pngs = [filePath];
+ }
+
+ const { authedFetch } = await import("./auth.js");
+ // Parallel page OCR — vision API calls are 2-5s each, summed
+ // sequentially that's 6-15s for a 3-page PDF. Parallel keeps
+ // total wall-time at ~max(page durations) = 3-5s.
+ const ocrPage = async (pngPath: string, idx: number): Promise => {
+ try {
+ const buf = readFileSync(pngPath);
+ const b64 = buf.toString("base64");
+ const mime = pngPath.endsWith(".png") ? "image/png" : "image/jpeg";
+ const res = await authedFetch("/v1/vision/ocr", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ imageDataUrl: `data:${mime};base64,${b64}`,
+ prompt: "OCR this page. Label every key field: dates (incl. expiration / expiry / valid until), names, numbers, ID/passport/license numbers, addresses, signatures. Be exhaustive.",
+ }),
+ });
+ if (!res.ok) {
+ const body = await res.text().catch(() => "");
+ this.logger.warn({ status: res.status, body: body.slice(0, 200), page: idx + 1 }, "Vision OCR page failed");
+ return null;
+ }
+ const data = (await res.json()) as { text?: string };
+ return data.text?.trim() || null;
+ } catch (err) {
+ this.logger.warn({ err, page: idx + 1 }, "OCR page exception");
+ return null;
+ }
+ };
+ const pageResults = await Promise.all(pngs.map((p, i) => ocrPage(p, i)));
+ const pageTexts: string[] = [];
+ pageResults.forEach((t, i) => {
+ if (t) pageTexts.push(pngs.length > 1 ? `--- page ${i + 1} ---\n${t}` : t);
+ });
+ const combined = pageTexts.length > 0 ? pageTexts.join("\n\n") : null;
+ if (combined) this.writeOcrCache(filePath, combined);
+ return combined;
+ } catch (err) {
+ this.logger.warn({ err }, "OCR exception");
+ return null;
+ } finally {
+ // Always clean up — even on exception mid-OCR or early-return.
+ // Without this, /tmp accumulates a tmpdir per OCR call forever.
+ if (needsCleanup) {
+ try {
+ const fs = await import("fs");
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ } catch {}
+ }
+ }
+ }
+
+ // Multi-page PDF → PNG renderer using macOS PDFKit via JXA.
+ // Returns an array of PNG paths (page-001.png, page-002.png, ...)
+ // in the output directory, capped at `maxPages`. Renders at 2x
+ // scale of the PDF's MediaBox for crisp OCR. Zero install — uses
+ // /usr/bin/osascript and the system PDFKit framework.
+ private renderPdfPages(pdfPath: string, outDir: string, maxPages: number): Promise {
+ return new Promise((resolve) => {
+ try { mkdirSync(outDir, { recursive: true }); } catch {}
+ const script = `
+ObjC.import('PDFKit');
+ObjC.import('AppKit');
+function run(argv) {
+ const inputPath = argv[0];
+ const outDir = argv[1];
+ const maxPages = parseInt(argv[2] || '5', 10);
+ const scale = parseFloat(argv[3] || '2.0');
+ const url = $.NSURL.fileURLWithPath(inputPath);
+ const pdfDoc = $.PDFDocument.alloc.initWithURL(url);
+ if (!pdfDoc || pdfDoc.isNil()) { return ''; }
+ const n = Math.min(pdfDoc.pageCount, maxPages);
+ const paths = [];
+ for (let i = 0; i < n; i++) {
+ const page = pdfDoc.pageAtIndex(i);
+ const bounds = page.boundsForBox($.kPDFDisplayBoxMediaBox);
+ const w = Math.max(1, Math.floor(bounds.size.width * scale));
+ const h = Math.max(1, Math.floor(bounds.size.height * scale));
+ const rep = $.NSBitmapImageRep.alloc.initWithBitmapDataPlanesPixelsWidePixelsHighBitsPerSampleSamplesPerPixelHasAlphaIsPlanarColorSpaceNameBytesPerRowBitsPerPixel(
+ $(), w, h, 8, 4, true, false, $.NSCalibratedRGBColorSpace, 0, 0,
+ );
+ const ctx = $.NSGraphicsContext.graphicsContextWithBitmapImageRep(rep);
+ $.NSGraphicsContext.setCurrentContext(ctx);
+ const cg = ctx.CGContext;
+ $.CGContextSaveGState(cg);
+ $.CGContextScaleCTM(cg, scale, scale);
+ page.drawWithBoxToContext($.kPDFDisplayBoxMediaBox, cg);
+ $.CGContextRestoreGState(cg);
+ const png = rep.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $());
+ const outPath = outDir + '/page-' + String(i+1).padStart(3,'0') + '.png';
+ png.writeToFileAtomically(outPath, true);
+ paths.push(outPath);
+ }
+ return paths.join('\\n');
+}`;
+ const proc = spawn("osascript", ["-l", "JavaScript", "-e", script, pdfPath, outDir, String(maxPages), "2.0"]);
+ let stdout = "";
+ let stderr = "";
+ const timer = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {}; resolve([]); }, 60_000);
+ proc.stdout.on("data", (d) => (stdout += d.toString()));
+ proc.stderr.on("data", (d) => (stderr += d.toString()));
+ proc.on("close", (code) => {
+ clearTimeout(timer);
+ if (code !== 0) {
+ this.logger.warn({ code, stderr: stderr.slice(0, 200), pdfPath }, "PDFKit JXA render failed");
+ resolve([]);
+ return;
+ }
+ const paths = stdout.trim().split("\n").map((s) => s.trim()).filter(Boolean);
+ resolve(paths);
+ });
+ proc.on("error", (err) => { clearTimeout(timer); this.logger.warn({ err }, "PDFKit JXA spawn error"); resolve([]); });
+ });
+ }
+
+ // Single-page fallback via qlmanage Quick Look. Used when PDFKit
+ // can't open the file (encrypted, malformed). Slower than the
+ // JXA path because qlmanage spins up the Quick Look service.
+ private renderPdfPage(pdfPath: string, outDir: string): Promise {
+ return new Promise((resolve) => {
+ try { mkdirSync(outDir, { recursive: true }); } catch {}
+ const proc = spawn("qlmanage", ["-t", "-s", "1600", "-o", outDir, pdfPath]);
+ const timer = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {}; resolve(false); }, 15_000);
+ proc.stderr.on("data", () => {});
+ proc.on("close", (code) => {
+ clearTimeout(timer);
+ resolve(code === 0);
+ });
+ proc.on("error", () => { clearTimeout(timer); resolve(false); });
+ });
+ }
+
+ // Pure-Node PDF text extraction via the pdf-parse package. Works
+ // on any platform without external binaries — perfect default
+ // when poppler isn't installed. Slower than pdftotext on large
+ // PDFs (loads into memory) but fine for typical doc sizes.
+ private async pdfParseNode(path: string): Promise {
+ try {
+ // Dynamic import so this module loads cleanly even if
+ // pdf-parse isn't installed (graceful no-op).
+ const mod = await import("pdf-parse").catch(() => null) as
+ | { default?: (b: Buffer) => Promise<{ text: string }>; (b: Buffer): Promise<{ text: string }> }
+ | null;
+ if (!mod) return null;
+ const parser = (mod as { default?: (b: Buffer) => Promise<{ text: string }> }).default
+ ?? (mod as unknown as (b: Buffer) => Promise<{ text: string }>);
+ const buf = readFileSync(path);
+ const out = await parser(buf);
+ return (out?.text || "").trim();
+ } catch (err) {
+ this.logger.debug({ err }, "pdf-parse failed");
+ return null;
+ }
+ }
+
+ private pdftotext(path: string): Promise {
+ return new Promise((resolve) => {
+ const proc = spawn("pdftotext", ["-layout", "-nopgbrk", path, "-"]);
+ let stdout = "", err = false;
+ const timer = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {}; resolve(null); }, 10_000);
+ proc.stdout.on("data", (d) => (stdout += d.toString()));
+ proc.on("error", () => { err = true; clearTimeout(timer); resolve(null); });
+ proc.on("close", (code) => {
+ clearTimeout(timer);
+ if (err) return;
+ if (code === 0 && stdout.length > 0) resolve(stdout);
+ else resolve(null);
+ });
+ });
+ }
+
+ // Spotlight indexes most files' content — `mdls -name kMDItemTextContent`
+ // returns the indexed text. Limited (Spotlight caps preview length)
+ // but works without any extra deps.
+ private spotlightContent(path: string): Promise {
+ return new Promise((resolve) => {
+ const proc = spawn("mdls", ["-raw", "-name", "kMDItemTextContent", path]);
+ let stdout = "";
+ const timer = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {}; resolve(null); }, 4000);
+ proc.stdout.on("data", (d) => (stdout += d.toString()));
+ proc.on("close", () => {
+ clearTimeout(timer);
+ const out = stdout.trim();
+ if (out && out !== "(null)") resolve(out);
+ else resolve(null);
+ });
+ proc.on("error", () => { clearTimeout(timer); resolve(null); });
+ });
+ }
+
+ private prettyPath(absolute: string): string {
+ const home = homedir();
+ if (absolute.startsWith(home)) return "~" + absolute.slice(home.length);
+ return absolute;
+ }
+
+ private audit(action: string, data: Record): void {
+ try {
+ const line = JSON.stringify({ ts: new Date().toISOString(), action, ...data }) + "\n";
+ appendFileSync(this.cfg.auditLogPath, line);
+ } catch {}
+ }
+}
+
+// ---- markers + helpers ---------------------------------------------------
+
+// Extract [ATTACH:/path/to/file.pdf] markers from an LLM reply. Used
+// by the bridge to detect attach intent + strip the marker from the
+// human-facing reply.
+const ATTACH_RE = /\[ATTACH:([^\]\n]+)\]/g;
+export interface ExtractedAttach {
+ cleanedText: string;
+ paths: string[];
+}
+export function extractAttachMarkers(text: string): ExtractedAttach {
+ const paths: string[] = [];
+ const cleaned = text.replace(ATTACH_RE, (_m, p) => {
+ paths.push(p.trim());
+ return "";
+ }).replace(/\n{3,}/g, "\n\n").trim();
+ return { cleanedText: cleaned, paths };
+}
+
+function humanBytes(n: number): string {
+ if (n < 1024) return `${n}B`;
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)}KB`;
+ return `${(n / 1024 / 1024).toFixed(1)}MB`;
+}
+
+function humanAgo(ms: number): string {
+ if (ms < 60_000) return "just now";
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`;
+ if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`;
+ if (ms < 7 * 86_400_000) return `${Math.floor(ms / 86_400_000)}d ago`;
+ return new Date(Date.now() - ms).toLocaleDateString();
+}
+
+// Tiny RTF stripper — enough to make `.rtf` files readable. Doesn't
+// preserve formatting, just yanks the visible text out.
+function stripRTF(rtf: string): string {
+ return rtf
+ .replace(/\\par[d]?/g, "\n")
+ .replace(/\\[a-z]+-?\d*\s?/g, "")
+ .replace(/[{}]/g, "")
+ .replace(/\\'[0-9a-fA-F]{2}/g, "")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+}
+
+// Re-export so the index barrel picks them up.
+export { dirname, basename };
diff --git a/services/control-plane/cmd/server/main.go b/services/control-plane/cmd/server/main.go
index 342e0f74..ccbc5e71 100644
--- a/services/control-plane/cmd/server/main.go
+++ b/services/control-plane/cmd/server/main.go
@@ -259,6 +259,7 @@ func main() {
// LLM proxy / completions endpoint.
httpMux.HandleFunc("POST /v1/completions", llmProxyHandler.Complete)
+ httpMux.HandleFunc("POST /v1/vision/ocr", llmProxyHandler.OCR)
// Agent AI generation endpoints.
httpMux.HandleFunc("POST /v1/agents/generate-spec", llmProxyHandler.GenerateAgentSpec)
diff --git a/services/control-plane/internal/handlers/llm_proxy.go b/services/control-plane/internal/handlers/llm_proxy.go
index 7ead79f7..8c307002 100644
--- a/services/control-plane/internal/handlers/llm_proxy.go
+++ b/services/control-plane/internal/handlers/llm_proxy.go
@@ -65,13 +65,13 @@ type completionMessage struct {
}
type completionResponse struct {
- Model string `json:"model"`
- Content string `json:"content"`
- TokensIn int `json:"tokensIn"`
- TokensOut int `json:"tokensOut"`
- CostUsd float64 `json:"costUsd"`
- Provider string `json:"provider"`
- FinishReason string `json:"finishReason"`
+ Model string `json:"model"`
+ Content string `json:"content"`
+ TokensIn int `json:"tokensIn"`
+ TokensOut int `json:"tokensOut"`
+ CostUsd float64 `json:"costUsd"`
+ Provider string `json:"provider"`
+ FinishReason string `json:"finishReason"`
}
// ---------- Provider key resolution ----------
@@ -148,7 +148,9 @@ func (h *LlmProxyHandler) resolveModelForTenant(ctx context.Context, tenantID, c
// env var so prod can't accidentally route through a dev tool.
//
// LANTERN_USE_CLAUDE_CODE=1 → enable; uses the user's Claude Max
-// subscription (no API credits burned)
+//
+// subscription (no API credits burned)
+//
// LANTERN_CLAUDE_BINARY=/path → override binary location
func claudeCodeBinary() string {
if os.Getenv("LANTERN_USE_CLAUDE_CODE") != "1" {
@@ -297,12 +299,12 @@ func (h *LlmProxyHandler) callLLMSync(ctx context.Context, provider, model, apiK
// resolveModel maps a capability name to a concrete provider + model.
// modelOption represents a model candidate for routing.
type modelOption struct {
- provider string
- model string
- costPer1MIn float64 // $/1M input tokens
+ provider string
+ model string
+ costPer1MIn float64 // $/1M input tokens
costPer1MOut float64 // $/1M output tokens
- quality int // 1-10, higher is better
- speed int // 1-10, higher is faster
+ quality int // 1-10, higher is better
+ speed int // 1-10, higher is faster
}
// All available models with their characteristics.
@@ -520,12 +522,12 @@ func (h *LlmProxyHandler) Complete(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, completionResponse{
- Model: usedModel,
- Content: text,
- Provider: usedProvider,
- TokensIn: int(tokensIn),
+ Model: usedModel,
+ Content: text,
+ Provider: usedProvider,
+ TokensIn: int(tokensIn),
TokensOut: int(tokensOut),
- CostUsd: estimateCost(usedProvider, usedModel, int(tokensIn), int(tokensOut)),
+ CostUsd: estimateCost(usedProvider, usedModel, int(tokensIn), int(tokensOut)),
})
return
}
@@ -1782,9 +1784,9 @@ func (h *LlmProxyHandler) callAnthropicWithTools(
// Anthropic response carries an array of content blocks; each is
// either {type:"text", text:""} or {type:"tool_use", id, name, input}.
var antResp struct {
- Content []map[string]any `json:"content"`
- StopReason string `json:"stop_reason"`
- Usage struct {
+ Content []map[string]any `json:"content"`
+ StopReason string `json:"stop_reason"`
+ Usage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
} `json:"usage"`
@@ -1848,8 +1850,8 @@ func (h *LlmProxyHandler) callAnthropicWithTools(
}
result, dispErr := dispatch(ctx, tu.Name, tu.Input)
tr := map[string]any{
- "type": "tool_result",
- "tool_use_id": tu.ID,
+ "type": "tool_result",
+ "tool_use_id": tu.ID,
}
if dispErr != nil {
inv.Error = dispErr.Error()
@@ -2027,3 +2029,116 @@ func (h *LlmProxyHandler) callLLMWithFailover(
}
return "", nil, "", "", 0, 0, fmt.Errorf("all configured providers failed: %w", lastErr)
}
+
+// ---------- OCR / Vision endpoint ----------
+
+// OCRRequest is the body of POST /v1/vision/ocr. The image is passed as
+// a data URL (data:image/png;base64,...) so the client doesn't need to
+// upload a binary multipart form. Prompt is optional — defaults to a
+// generic OCR system prompt tuned for ID documents, forms, and
+// receipts (the personal-docs use case).
+type ocrRequest struct {
+ ImageDataUrl string `json:"imageDataUrl"`
+ Prompt string `json:"prompt,omitempty"`
+ Model string `json:"model,omitempty"`
+}
+
+type ocrResponse struct {
+ Text string `json:"text"`
+ Model string `json:"model"`
+ Provider string `json:"provider"`
+}
+
+// OCR handles POST /v1/vision/ocr. Uses the tenant's OpenAI key + gpt-4o
+// vision to extract text from a base64 image. Returns 400 if no OpenAI
+// key is configured (Anthropic vision is a future option; for now OCR
+// is OpenAI-only since gpt-4o vision is materially better than Claude
+// at dense ID/form extraction).
+func (h *LlmProxyHandler) OCR(w http.ResponseWriter, r *http.Request) {
+ ctx, tenantID, err := h.contextWithTenant(r)
+ if err != nil {
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+ return
+ }
+
+ var req ocrRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
+ return
+ }
+ if req.ImageDataUrl == "" || !strings.HasPrefix(req.ImageDataUrl, "data:") {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "imageDataUrl must be a data: URL"})
+ return
+ }
+
+ apiKey, err := h.resolveProviderKey(ctx, tenantID, "openai")
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "OCR requires an OpenAI key — add one in Settings > LLM Providers (gpt-4o vision)",
+ })
+ return
+ }
+
+ model := req.Model
+ if model == "" {
+ model = "gpt-4o-mini"
+ }
+ prompt := req.Prompt
+ if prompt == "" {
+ prompt = "OCR this document. Label fields clearly."
+ }
+ system := "You are an OCR engine. Extract ALL visible text from the image, preserving structure. Include numbers, dates, names exactly as shown. If the image is a form, ID, passport, license, or receipt, LABEL key fields explicitly (e.g., 'Date of Birth: ...', 'Expiration Date: ...', 'Passport Number: ...', 'License #: ...'). Be exhaustive — every visible text element matters. No commentary, just the extracted text."
+
+ body := map[string]any{
+ "model": model,
+ "messages": []map[string]any{
+ {"role": "system", "content": system},
+ {"role": "user", "content": []map[string]any{
+ {"type": "image_url", "image_url": map[string]any{"url": req.ImageDataUrl}},
+ {"type": "text", "text": prompt},
+ }},
+ },
+ "max_tokens": 2000,
+ }
+ bb, _ := json.Marshal(body)
+ httpReq, err := http.NewRequestWithContext(ctx, "POST", "https://api.openai.com/v1/chat/completions", bytes.NewReader(bb))
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to build request"})
+ return
+ }
+ httpReq.Header.Set("Content-Type", "application/json")
+ httpReq.Header.Set("Authorization", "Bearer "+apiKey)
+
+ client := &http.Client{Timeout: 90 * time.Second}
+ resp, err := client.Do(httpReq)
+ if err != nil {
+ h.logger().Error("OCR vision request failed", zap.Error(err))
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": "failed to reach OpenAI: " + err.Error()})
+ return
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ errBody, _ := io.ReadAll(resp.Body)
+ h.logger().Warn("OCR returned non-200", zap.Int("status", resp.StatusCode), zap.String("body", string(errBody)))
+ writeJSON(w, resp.StatusCode, map[string]string{"error": "OpenAI vision error", "details": string(errBody)})
+ return
+ }
+
+ var result struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": "failed to parse OpenAI response"})
+ return
+ }
+ text := ""
+ if len(result.Choices) > 0 {
+ text = result.Choices[0].Message.Content
+ }
+ writeJSON(w, http.StatusOK, ocrResponse{Text: text, Model: model, Provider: "openai"})
+}
diff --git a/services/imessage-bridge/src/applescript.ts b/services/imessage-bridge/src/applescript.ts
index fb9df8f6..a075c655 100644
--- a/services/imessage-bridge/src/applescript.ts
+++ b/services/imessage-bridge/src/applescript.ts
@@ -98,6 +98,64 @@ export class IMessageSender {
return { ok: false, reason: lastErr.slice(0, 300) };
}
+ // Send a FILE attachment to `to` via Messages.app. AppleScript's
+ // Messages dictionary supports `send ` for attachments
+ // — the file must already exist on disk and be FULLY MATERIALIZED
+ // (not an iCloud Drive 0-byte placeholder). We pre-materialize via
+ // `brctl download` before sending so iCloud Drive optimized-
+ // storage files don't get sent as broken stubs.
+ async sendFile(to: string, filePath: string): Promise<{ ok: true } | { ok: false; reason: string }> {
+ if (!to || !filePath) {
+ return { ok: false, reason: "to + filePath required" };
+ }
+ // If the file is in iCloud Drive and only present as a stub,
+ // brctl download materializes it locally. No-op for files that
+ // are already fully on disk OR not in iCloud. Best-effort —
+ // failures here don't block the send (the file might still work).
+ if (filePath.includes("Mobile Documents/com~apple~CloudDocs") || filePath.includes("iCloud")) {
+ await this.brctlDownload(filePath);
+ }
+ const aplStr = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
+ // POSIX file → `as alias` is critical: without `as alias`,
+ // Messages.app attaches a reference instead of the real file
+ // content, and the recipient sees a "PDF Document" placeholder
+ // that can't be opened or downloaded. The `as alias` cast
+ // resolves the path to a Finder alias the Messages send
+ // pipeline uploads in full.
+ const strategies = [
+ `tell application "Messages"
+ set targetService to 1st service whose service type = iMessage
+ set targetBuddy to buddy ${aplStr(to)} of targetService
+ set theFile to (POSIX file ${aplStr(filePath)}) as alias
+ send theFile to targetBuddy
+ end tell`,
+ `tell application "Messages"
+ set theFile to (POSIX file ${aplStr(filePath)}) as alias
+ send theFile to buddy ${aplStr(to)}
+ end tell`,
+ ];
+ let lastErr = "";
+ for (const script of strategies) {
+ const res = await this.runOsascript(script);
+ if (res.ok) return { ok: true };
+ lastErr = res.reason;
+ if (res.reason.includes("not authorized") || res.reason.includes("-1743")) return res;
+ }
+ return { ok: false, reason: lastErr.slice(0, 300) };
+ }
+
+ // Force iCloud Drive to materialize a file from cloud → local disk.
+ // No-op for files not in iCloud. Short timeout: large files take
+ // longer but we'd rather fail fast than block the iMessage UX.
+ private async brctlDownload(filePath: string): Promise {
+ return new Promise((resolve) => {
+ const proc = spawn("brctl", ["download", filePath]);
+ const timer = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {}; resolve(); }, 15_000);
+ proc.on("close", () => { clearTimeout(timer); resolve(); });
+ proc.on("error", () => { clearTimeout(timer); resolve(); });
+ });
+ }
+
// Run an osascript command. Centralized so the multi-strategy send()
// doesn't duplicate process management.
private runOsascript(script: string): Promise<{ ok: true } | { ok: false; reason: string }> {
diff --git a/services/imessage-bridge/src/session.ts b/services/imessage-bridge/src/session.ts
index c7ee8b99..259f35f6 100644
--- a/services/imessage-bridge/src/session.ts
+++ b/services/imessage-bridge/src/session.ts
@@ -19,7 +19,7 @@
// - Persisted state in bridge_state//
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
-import { join } from "path";
+import { basename, join } from "path";
import type { Logger } from "pino";
import type { WebSocket } from "ws";
@@ -44,6 +44,15 @@ import { parseVoiceCommand } from "@lantern/bridge-core/voice-commands";
import { scheduleDigest, defaultDigestConfig } from "@lantern/bridge-core/daily-digest";
import { OfflineMonitor, defaultOfflineMonitorConfig } from "@lantern/bridge-core/offline-monitor";
import { EmailMirror } from "@lantern/bridge-core/email-mirror";
+import {
+ PersonalDocs,
+ defaultPersonalDocsConfig,
+ looksLikeDocQuery,
+ looksLikeDocFollowup,
+ extractAttachMarkers,
+} from "@lantern/bridge-core/personal-docs";
+import { MacActions, extractActionMarkers } from "@lantern/bridge-core/mac-actions";
+import { humanizeWithOffer, looksLikeConfirmation, type PendingOffer } from "@lantern/bridge-core/humanize";
export type IMessageConnectionState =
| "starting"
@@ -56,6 +65,8 @@ interface PersistedState {
muted?: boolean;
paused?: Record; // handle -> until_ms
monitoredChats?: number[]; // chat ROWIDs to act in
+ personalDocsEnabled?: boolean; // owner toggle for local-file Q&A; default true
+ killSwitch?: boolean; // master OFF — bot refuses everything except killswitch-off
}
const POLL_INTERVAL_MS = 1500;
@@ -83,6 +94,13 @@ export class IMessageSession {
private muted = false;
private pausedUntil: Map = new Map();
private monitoredChats: Set = new Set();
+ // Personal-docs Q&A toggle. Default ON. Owner can disable from
+ // self-chat at any time with "docs off" — survives restart.
+ private personalDocsEnabled = true;
+ // Master kill switch. When engaged the bridge IGNORES every inbound
+ // (auto-reply, doc queries, commands, mentions) EXCEPT the
+ // killswitch-off command itself. Survives restart.
+ private killSwitch = false;
// Subscribers
private sockets: Set = new Set();
@@ -112,11 +130,34 @@ export class IMessageSession {
private recentBridgeSends: Array<{ text: string; ts: number }> = [];
private static readonly BRIDGE_SEND_DEDUP_MS = 60_000;
+ // INBOUND dedup. When the user has TWO Apple IDs signed into
+ // Messages.app on this Mac (a common configuration: primary +
+ // family-share or bot account), the same logical message lands in
+ // chat.db TWICE — once per account's view of the conversation, with
+ // different rowids. Without this dedup, handleNewRow fires twice →
+ // handleOwnerDocQuery runs twice → the LLM is billed twice and the
+ // user sees two slightly different replies. Window is short (5s)
+ // because the dual-row gap is sub-second; longer than 5s means a
+ // legitimate retry by the user.
+ private recentInbound: Array<{ key: string; ts: number }> = [];
+ private static readonly INBOUND_DEDUP_MS = 5_000;
+
+ // Per-chat cache of the most recent offer we made (e.g. "want me
+ // to add a renewal reminder?"). When the user confirms with
+ // "yes" / "sure" / "do it" within OFFER_TTL_MS, the bridge fires
+ // the action DETERMINISTICALLY — no LLM round trip. Bypasses
+ // hallucinations where the LLM claimed "we've already set it"
+ // without ever emitting a [CALENDAR:...] marker.
+ private pendingOffers: Map = new Map();
+ private static readonly OFFER_TTL_MS = 10 * 60_000; // 10 min
+
// Futuristic helpers
private agent: AgentClient;
private media: MediaHandler;
private personal: PersonalClient;
private calendar: CalendarLookup;
+ private docs: PersonalDocs;
+ private macActions: MacActions;
constructor(tenantId: string, baseStateDir: string, logger: Logger) {
this.tenantId = tenantId;
@@ -132,6 +173,8 @@ export class IMessageSession {
this.media = new MediaHandler(this.logger);
this.personal = new PersonalClient(this.logger);
this.calendar = new CalendarLookup(this.logger);
+ this.docs = new PersonalDocs(defaultPersonalDocsConfig(this.stateDir), this.logger);
+ this.macActions = new MacActions(this.logger);
mkdirSync(this.stateDir, { recursive: true });
this.loadState();
@@ -349,9 +392,12 @@ export class IMessageSession {
const e = this.recentBridgeSends[i];
if (now - e.ts > IMessageSession.BRIDGE_SEND_DEDUP_MS) break;
if (e.text.trim() === t) {
- // Consume so a single send isn't matched twice if a duplicate
- // delivery shows up.
- this.recentBridgeSends.splice(i, 1);
+ // Do NOT consume — the same reply can land in chat.db twice
+ // when the Mac is signed into multiple iMessage accounts
+ // (once as is_from_me=1 from the sending account, once as
+ // is_from_me=0 from the receiving account's own sync view).
+ // Both arrivals must be deduped. Entry is GC'd by
+ // recordBridgeSend after BRIDGE_SEND_DEDUP_MS.
return true;
}
}
@@ -409,6 +455,43 @@ export class IMessageSession {
const unixMs = appleNsToUnixMs(row.date);
const isGroup = row.chatDisplayName !== "" || row.handle === "";
+ // INBOUND DEDUP. When the user has 2+ Apple IDs signed into
+ // Messages on this Mac, the same logical message lands twice
+ // (one row per account view) with different rowids. Key on
+ // (handle, isFromMe, text) within a 5s window — too tight for
+ // false legitimate retries; loose enough to catch the dual-row
+ // race even on a busy laptop.
+ const text = (row.text || "").trim();
+ if (text && row.handle) {
+ const key = `${row.handle}|${row.isFromMe ? 1 : 0}|${text}`;
+ const now = Date.now();
+ // GC first
+ this.recentInbound = this.recentInbound.filter((e) => now - e.ts < IMessageSession.INBOUND_DEDUP_MS);
+ const seen = this.recentInbound.find((e) => e.key === key);
+ if (seen) {
+ this.logger.debug({ rowid: row.rowid, handle: row.handle, textPreview: text.slice(0, 60) }, "duplicate inbound — skipping (multi-Apple-ID echo)");
+ return;
+ }
+ this.recentInbound.push({ key, ts: now });
+ }
+
+ // KILL SWITCH gate. When engaged, the bridge IGNORES every inbound
+ // EXCEPT a killswitch-off command from the owner in self-chat.
+ // This is the master safety lever — useful if the bot misbehaves
+ // or the user needs an instant cone of silence (e.g., handing the
+ // phone to someone).
+ if (this.killSwitch) {
+ const txt = (row.text || "").trim();
+ const isOwnerSelf = this.isOwnerChatRow(row);
+ const cmd = txt ? parseNLCommand(txt) : null;
+ const isReleaseCmd = !!cmd && cmd.action === "killswitch-off";
+ if (!(isOwnerSelf && isReleaseCmd)) {
+ return; // total silence
+ }
+ // Release command — fall through to normal handling so the
+ // command dispatcher fires + acknowledges.
+ }
+
// --- Outbound (user's own message) ---
if (row.isFromMe) {
const text = (row.text || "").trim();
@@ -467,6 +550,30 @@ export class IMessageSession {
void this.handleOwnerCommand(row.handle || this.ownHandleGuess(), parsed);
return;
}
+
+ // Personal-docs Q&A. SECURITY: triple-gated to the owner's
+ // self-chat:
+ // 1) personalDocsEnabled toggle must be ON (owner-controlled,
+ // survives restart)
+ // 2) chat must be the owner's self-chat (chatIdentifier ==
+ // participant handle == owner's own handle, single
+ // participant)
+ // 3) the row handle must match LANTERN_IMESSAGE_OWNER_HANDLE
+ // when set (last line of defense if isSelfChat() heuristic
+ // ever misfires).
+ // Any DM from a contact or any group message is hard-rejected
+ // here. See bridge-core/personal-docs.ts for the file-system
+ // security model (allowed roots + audit log).
+ if (
+ this.personalDocsEnabled
+ && !isGroup
+ && this.isOwnerChatRow(row)
+ && looksLikeDocQuery(text)
+ ) {
+ void this.handleOwnerDocQuery(row.handle, text);
+ return;
+ }
+
// Capture as ownerSentHistory exemplar for style cloning. Skip
// groups (mixed register), skip empty/short.
if (!isGroup && row.handle && text.length >= 3) {
@@ -495,12 +602,172 @@ export class IMessageSession {
void this.handleInbound(row, unixMs, isGroup);
}
+ // Cache of self-chat ROWIDs discovered by querying chat.db on
+ // demand. Populated lazily — first time we hit a row from a chat,
+ // we check if that chat has only the user as participant (or is
+ // chat_identifier == own handle) and remember.
+ private selfChatRowIds: Set = new Set();
+
+ // Per-chat timestamp of the last doc query. Lets us recognize
+ // short follow-ups ("send it", "yes", "the first one") as
+ // continuations within a 5-minute window.
+ private lastDocQueryAt: Map = new Map();
+
+ // True when the chat ROWID is the user's self-chat (messaging
+ // yourself — either same Apple ID across devices, or a 1-on-1
+ // with your own handle as the only participant). The Mac's
+ // chat.db sees these as is_from_me=0 because the message came
+ // from a DIFFERENT DEVICE (your phone) even though it's the same
+ // account.
+ private isSelfChat(chatRowid: number, handle: string): boolean {
+ if (this.selfChatRowIds.has(chatRowid)) return true;
+ // Check chat.db: self-chats have chat_identifier matching the
+ // owner's own handle. We don't know the owner's handle for sure
+ // (LANTERN_IMESSAGE_OWNER_HANDLE may be unset), so we use a
+ // heuristic: if the participant count of this chat is exactly 1
+ // AND the participant handle matches `handle`, this row is a
+ // self-chat conversation (you DM'ing yourself across devices).
+ try {
+ const chats = this.db.listChats();
+ const c = chats.find((x) => x.rowid === chatRowid);
+ if (c && c.participantCount === 1 && c.chatIdentifier === handle) {
+ this.selfChatRowIds.add(chatRowid);
+ return true;
+ }
+ } catch {}
+ // Env override — set LANTERN_IMESSAGE_OWNER_HANDLE to your own
+ // number to force-match. Useful when chat.db's participantCount
+ // is wrong (rare, but happens with merged contacts).
+ const ownerEnv = process.env.LANTERN_IMESSAGE_OWNER_HANDLE;
+ if (ownerEnv && handle === ownerEnv) {
+ this.selfChatRowIds.add(chatRowid);
+ return true;
+ }
+ return false;
+ }
+
+ // Owner-chat check used as the security gate for personal-docs
+ // Q&A, the killswitch-release command, and all agentic actions.
+ //
+ // Two supported topologies:
+ // (A) DEDICATED BOT MODE — this Mac is signed into a SEPARATE
+ // Apple ID that acts as the bot. The owner DMs the bot from
+ // their primary Apple ID on their phone. Messages arrive as
+ // 1-on-1 with row.handle == owner's primary handle. Set
+ // LANTERN_IMESSAGE_OWNER_HANDLE to the owner's primary phone
+ // or email (e.g. "+15125551234" or "shekhar@icloud.com").
+ // (B) SELF-CHAT MODE — single Apple ID across owner's devices.
+ // Owner messages themselves; the chat is a self-chat (1
+ // participant, chatIdentifier == handle).
+ //
+ // Both pass when row.handle is provided, the chat isn't a group,
+ // AND one of:
+ // - row.handle matches LANTERN_IMESSAGE_OWNER_HANDLE (mode A,
+ // authoritative when set), OR
+ // - chat is a self-chat by the heuristic (mode B fallback).
+ //
+ // Normalization strips spaces, dashes, parens, dots so
+ // "+1 (512) 555-1234" and "+15125551234" both match.
+ private isOwnerChatRow(row: IMessageRow): boolean {
+ if (!row.handle) return false;
+ const isGroup = row.chatDisplayName !== "" || row.handle === "";
+ if (isGroup) return false;
+ const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9@.]/g, "");
+ const ownerEnv = (process.env.LANTERN_IMESSAGE_OWNER_HANDLE || "").trim();
+ if (ownerEnv && norm(row.handle) === norm(ownerEnv)) return true;
+ // Self-chat fallback when env not set OR env was set but doesn't
+ // match (covers users who run BOTH topologies on the same Mac).
+ if (this.isSelfChat(row.chatRowid, row.handle)) return true;
+ return false;
+ }
+
// Asynchronous because media annotation may call out to Whisper +
// vision LLM. Fire-and-forget from the polling loop; failures here
// don't block the next poll.
private async handleInbound(row: IMessageRow, unixMs: number, isGroup: boolean): Promise {
let text = row.text || "";
+ // Cross-device echo guard. When the bridge sends a reply, the
+ // user's other device (phone) receives it from CloudKit sync,
+ // and chat.db records it with is_from_me=0 (because the Mac
+ // didn't author it — the phone did, even though it's the same
+ // Apple ID). Without this check we'd treat our own reply as a
+ // new inbound from the user, re-trigger the doc-query pipeline,
+ // and infinite-loop. The dedup uses the same text-based window
+ // we use in the isFromMe branch.
+ if (text && this.isOwnBridgeSend(text)) {
+ this.logger.debug({ rowid: row.rowid }, "inbound matched recent bridge send — skipping (echo)");
+ return;
+ }
+
+ // Visibility into the inbound pipeline. Every inbound logs the
+ // gating signals so future "why didn't it reply" issues are
+ // diagnosable from the log alone.
+ if (text) {
+ const isSelf = !isGroup && row.handle ? this.isSelfChat(row.chatRowid, row.handle) : false;
+ this.logger.info({
+ rowid: row.rowid, chatRowid: row.chatRowid, handle: row.handle,
+ isGroup, isSelf, textPreview: text.slice(0, 60),
+ docQuery: looksLikeDocQuery(text),
+ }, "inbound");
+ }
+
+ // OWNER COMMAND PATH — when the owner DMs the bot (dedicated-bot
+ // mode, isFromMe=0), the isFromMe branch above never fires, so
+ // commands like "lantern status", "/bot off", "docs off",
+ // "kill switch on" would silently fall through to natural-reply.
+ // Catch them here so the same command vocabulary works from both
+ // topologies.
+ if (text && !isGroup && this.isOwnerChatRow(row)) {
+ const parsed = parseNLCommand(text);
+ if (parsed) {
+ void this.handleOwnerCommand(row.handle, parsed);
+ return;
+ }
+ }
+
+ // Personal-docs interception — fires BEFORE the normal inbound
+ // pipeline. Handles two topologies via isOwnerChatRow:
+ // (A) DEDICATED BOT MODE — owner DMs the bot from their primary
+ // Apple ID; row.handle matches LANTERN_IMESSAGE_OWNER_HANDLE.
+ // (B) SELF-CHAT MODE — single Apple ID across devices; row.handle
+ // == chatIdentifier == owner's own handle.
+ // SECURITY: triple-gated by personalDocsEnabled toggle, group-check,
+ // and the owner-chat guard. No DM from a contact can reach here.
+ if (this.personalDocsEnabled && !isGroup && this.isOwnerChatRow(row) && text) {
+ // CONFIRMATION INTERCEPT: when there's a pending offer for
+ // this chat and the user replied with a clean affirmation
+ // ("yes" / "sure" / "do it"), execute the action directly.
+ // No LLM round trip — avoids the hallucination where the
+ // LLM claimed it set the reminder without emitting the
+ // marker.
+ // First, sweep expired offers so the Map can't grow
+ // unboundedly across long-lived sessions.
+ this.gcPendingOffers();
+ const cachedOffer = this.pendingOffers.get(row.handle);
+ if (cachedOffer && looksLikeConfirmation(text)) {
+ this.logger.info({ kind: cachedOffer.kind, chat: row.handle }, "executing cached offer on confirmation");
+ this.pendingOffers.delete(row.handle); // one-shot
+ void this.executeCachedOffer(row.handle, cachedOffer);
+ return;
+ }
+
+ const isDocQuery = looksLikeDocQuery(text);
+ const lastDocAt = this.lastDocQueryAt.get(row.chatRowid) || 0;
+ const isFollowup = !isDocQuery
+ && looksLikeDocFollowup(text)
+ && Date.now() - lastDocAt < 5 * 60_000;
+ if (isDocQuery || isFollowup) {
+ this.logger.info(
+ { query: text.slice(0, 80), chatRowid: row.chatRowid, mode: isDocQuery ? "query" : "followup" },
+ "self-chat doc query (cross-device)",
+ );
+ this.lastDocQueryAt.set(row.chatRowid, Date.now());
+ void this.handleOwnerDocQuery(row.handle, text, { followup: isFollowup });
+ return;
+ }
+ }
+
// Media: attach a synthetic-text annotation when an attachment is
// present and no text was sent. Reply pipeline treats the
// annotation as the inbound body.
@@ -768,7 +1035,8 @@ export class IMessageSession {
const pausedCount = [...this.pausedUntil.values()].filter((t) => t > Date.now()).length;
return [
`🟢 *Lantern iMessage*`,
- `• bot: ${diag.muted ? "off" : "on"}`,
+ `• bot: ${this.killSwitch ? "🚨 KILL SWITCH ENGAGED" : diag.muted ? "off" : "on"}`,
+ `• personal-docs: ${this.personalDocsEnabled ? "on" : "off"}`,
`• paused contacts: ${pausedCount}`,
`• monitored chats: ${diag.monitoredChats.length}`,
`• uptime: ${Math.round(diag.uptimeMs / 60_000)}m`,
@@ -795,9 +1063,92 @@ export class IMessageSession {
this.pausedUntil.clear();
this.persist();
},
+ setDocsEnabled: async (enabled: boolean) => {
+ this.personalDocsEnabled = enabled;
+ this.persist();
+ this.broadcast({
+ type: "activity",
+ data: { kind: "system", summary: `personal-docs ${enabled ? "ENABLED" : "DISABLED"}`, timestamp: Date.now() },
+ });
+ },
+ setKillSwitch: async (engaged: boolean) => {
+ this.killSwitch = engaged;
+ this.persist();
+ this.broadcast({
+ type: "activity",
+ data: { kind: engaged ? "bot_off" : "bot_on", summary: `🚨 kill switch ${engaged ? "ENGAGED" : "RELEASED"}`, timestamp: Date.now() },
+ });
+ // When engaging, also mirror to email so the user gets an
+ // audit trail (especially if they engaged it remotely and
+ // want a confirmation outside the chat).
+ if (engaged) {
+ void this.mirrorToEmail("🚨 KILL SWITCH ENGAGED — Lantern iMessage bridge is silent until you release it.");
+ }
+ },
});
}
+ // Execute a cached offer from humanize's detectOfferInReply.
+ // Bypasses the LLM entirely for confirmations — deterministic.
+ // Sends a natural confirmation back to the chat.
+ private async executeCachedOffer(jid: string, offer: PendingOffer): Promise {
+ if (offer.kind === "calendar-reminder" && offer.targetIsoDate && offer.leadDays) {
+ // Compute the reminder date: targetIsoDate minus leadDays.
+ const target = new Date(`${offer.targetIsoDate}T09:00:00`);
+ const reminderDate = new Date(target.getTime() - offer.leadDays * 86_400_000);
+ const startIso = reminderDate.toISOString().slice(0, 19);
+ const endIso = new Date(reminderDate.getTime() + 30 * 60_000).toISOString().slice(0, 19);
+ try {
+ const res = await this.macActions.createCalendarEvent({
+ title: offer.title || "Renewal reminder",
+ start: startIso,
+ end: endIso,
+ notes: `Auto-set by Lantern. Original expiration: ${offer.targetIsoDate}.`,
+ });
+ if (res.ok) {
+ const friendly = reminderDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
+ await this.send(jid, `📅 done — reminder set for ${friendly} (${offer.leadDays} days before). ${res.detail || ""}`);
+ } else {
+ await this.send(jid, `(couldn't add to calendar: ${res.reason})`);
+ }
+ this.logger.info({ kind: offer.kind, ok: res.ok, info: res.ok ? res.detail : res.reason }, "cached-offer executed");
+ } catch (err) {
+ this.logger.error({ err }, "calendar offer execution failed");
+ await this.send(jid, `(calendar add failed — try again)`);
+ }
+ return;
+ }
+ if (offer.kind === "save-note" && offer.noteTitle && offer.noteBody) {
+ try {
+ const res = await this.macActions.createNote({
+ title: offer.noteTitle,
+ body: offer.noteBody,
+ });
+ if (res.ok) {
+ await this.send(jid, `🗒 saved as a note — "${offer.noteTitle}". find it in Notes.app.`);
+ } else {
+ await this.send(jid, `(couldn't save the note: ${res.reason})`);
+ }
+ this.logger.info({ kind: offer.kind, ok: res.ok }, "cached-offer executed");
+ } catch (err) {
+ this.logger.error({ err }, "note offer execution failed");
+ await this.send(jid, `(note save failed — try again)`);
+ }
+ return;
+ }
+ }
+
+ // GC pendingOffers Map — sweep entries older than OFFER_TTL_MS so
+ // a long-lived session can't accumulate stale offers indefinitely.
+ // Called lazily on each confirmation-intercept check (cheap O(n)).
+ private gcPendingOffers(): void {
+ if (this.pendingOffers.size === 0) return;
+ const cutoff = Date.now() - IMessageSession.OFFER_TTL_MS;
+ for (const [key, offer] of this.pendingOffers) {
+ if (offer.issuedAt < cutoff) this.pendingOffers.delete(key);
+ }
+ }
+
// Best-effort guess at the owner's own iMessage handle for self-chat
// replies. iMessage doesn't expose this directly via chat.db without
// extra joins; we read LANTERN_IMESSAGE_OWNER_HANDLE env when set.
@@ -825,6 +1176,184 @@ export class IMessageSession {
return this.contactNames.get(handle) || handle;
}
+ // Owner asked a doc-query in self-chat. Search local files, inject
+ // results into the LLM prompt, send the reply back. If the LLM
+ // included [ATTACH:/path] markers, send those files as iMessage
+ // attachments via AppleScript.
+ //
+ // When `opts.followup` is true, we DON'T do a fresh search —
+ // we trust the agent's session memory of the previous turn to
+ // know which file the user is referring to. This handles "send
+ // it" / "yes" / "the first one" continuations.
+ private async handleOwnerDocQuery(
+ jid: string,
+ query: string,
+ opts: { followup?: boolean } = {},
+ ): Promise {
+ this.logger.info({ query: query.slice(0, 80), followup: !!opts.followup }, "owner doc query");
+ this.broadcast({
+ type: "activity",
+ data: {
+ kind: "system",
+ summary: `📁 doc ${opts.followup ? "followup" : "query"}: ${query.slice(0, 60)}`,
+ timestamp: Date.now(),
+ },
+ });
+
+ // Immediate ack — doc queries can take 10-15s for OCR'd PDFs.
+ // Without this signal, the user thinks the bot is dead. We await
+ // the send so this message lands BEFORE the answer (which might
+ // come back fast for cached / non-OCR queries).
+ const ackText = opts.followup
+ ? "📎 grabbing it…"
+ : "📁 one sec — looking through your files…";
+ await this.send(jid, ackText);
+
+ // If the heavy work runs long (>6s), send ONE progress nudge so
+ // the user knows the bot is still alive on big PDFs. Cancelled
+ // when the work finishes.
+ const startedAt = Date.now();
+ let progressFired = false;
+ const progressTimer = setTimeout(() => {
+ progressFired = true;
+ void this.send(jid, "📷 still scanning — almost there…");
+ }, 6000);
+ const clearProgress = () => {
+ clearTimeout(progressTimer);
+ if (progressFired) {
+ // GC-friendly noop. The user already saw the progress msg.
+ }
+ };
+
+ // Followups skip a fresh search — the agent already has the file
+ // paths in its session history. Fresh queries inject a context
+ // block with search results + top-match content.
+ const contextBlock = opts.followup
+ ? "\n\n(continuing previous doc query — use the file paths from your prior reply for any [ATTACH:...] markers)"
+ : await this.docs.buildContextBlock(query, { includeBodies: true });
+ this.logger.info({ ms: Date.now() - startedAt }, "doc context built");
+
+ // Persona: you ARE the user, answering yourself about your own
+ // docs. Brief, factual, lowercase. Use [ATTACH:...] markers to
+ // request file delivery.
+ const today = new Date().toISOString().slice(0, 10);
+ const systemHint = [
+ "You are Lantern — Shekhar's personal agent, replying in his iMessage self-chat.",
+ `Today is ${today}. You can search his Mac, read local files (incl. OCR scanned PDFs), and take native actions on his behalf.`,
+ "",
+ "STYLE — sophisticated, natural, agentic. Like Jarvis: warm, concise, never robotic.",
+ " • Direct answers first. No 'I'd be happy to' / 'feel free'.",
+ " • Lowercase, conversational. 1-3 short lines max.",
+ " • State the FACT directly when you have it. If the OCR'd content gives the answer, give it. Don't say 'check the file'.",
+ "",
+ "AGENTIC FOLLOW-UPS — MANDATORY when applicable:",
+ " • Answer mentions an EXPIRY / DUE DATE / DEADLINE → ALWAYS add a second line offering a calendar event AND/OR mail-renewal reminder. Phrase as a question.",
+ " Example: 'want me to add a renewal reminder to your calendar 60 days before?'",
+ " • Answer mentions a NUMBER worth remembering (passport #, license #, account #, SSN-last-4) → offer to save it as a Note.",
+ " • Answer references a FILE the user might want delivered → offer to attach it.",
+ " • If the answer is purely factual and none of the above apply, no offer is needed.",
+ "",
+ "ACTIONS — you can take these on his Mac. Emit ONE marker per action on its own line at the END of your reply.",
+ " • Attach file: `[ATTACH:/exact/absolute/path]` (COPY paths VERBATIM from the context block)",
+ " • Calendar event: `[CALENDAR:Title|2026-08-19T09:00:00|2026-08-19T10:00:00|Optional notes]` (local TZ, ISO)",
+ " • Note: `[NOTE:Title|Body text]`",
+ " • Mail draft: `[MAIL:to@x.com,b@y.com|Subject|Body]` (opens in Mail.app for review)",
+ "",
+ "OFFER-then-CONFIRM rule: Don't fire an action on the FIRST mention. Instead END with one short question. If the user confirms ('yes', 'sure', 'do it', 'go') in the NEXT turn, THEN emit the marker — and put it on its own line so the bridge can parse it. Compute the actual date (e.g., 60 days before 14/09/2031 = 16/07/2031) when you emit the marker.",
+ "",
+ "PATHS: Many files live under iCloud Drive at `/Users/shakes/Library/Mobile Documents/com~apple~CloudDocs/...`. Never substitute `/Users/shakes/Documents/...`. If a path isn't in the context block, say you need to look again.",
+ "",
+ contextBlock,
+ ].join("\n");
+
+ const draft = await this.agent.respondTo(jid, query, systemHint);
+ clearProgress();
+ this.logger.info({ totalMs: Date.now() - startedAt, hadDraft: !!draft }, "doc query done");
+ if (!draft) {
+ void this.send(jid, "couldn't reach the agent — try again in a sec.");
+ return;
+ }
+
+ // Extract action markers (attach, calendar, note, mail) in one
+ // pass. Strip them all from the user-facing text so the reply
+ // reads cleanly. Actions then execute one-by-one with concise
+ // confirmations back to the chat.
+ const { cleanedText: textNoAttach, paths } = extractAttachMarkers(draft);
+ const { cleanedText: finalText, calendarEvents, notes, mailDrafts } = extractActionMarkers(textNoAttach);
+
+ // Humanize: friendly dates + guaranteed follow-up offer. The
+ // returned `offer` lets us deterministically execute the action
+ // on the next-turn confirmation — bypasses the LLM's tendency
+ // to claim "already done" without emitting the marker.
+ const { reply: polished, offer } = humanizeWithOffer(finalText);
+
+ if (polished) {
+ await this.send(jid, polished);
+ }
+
+ // Cache the offer keyed by chat so the followup ("yes") in the
+ // same self-chat can find + execute it. Overwrites any prior
+ // offer (we only care about the most recent one).
+ if (offer && jid) {
+ this.pendingOffers.set(jid, offer);
+ }
+
+ // Deliver attachments. Path is rescued via basename search if
+ // the LLM hallucinated a parent directory.
+ for (const claimedPath of paths) {
+ const resolved = await this.docs.resolveAttachPath(claimedPath);
+ if (!resolved.ok) {
+ this.logger.warn({ claimedPath, reason: resolved.reason }, "ATTACH path unresolved — skipped");
+ await this.send(jid, `(couldn't attach — ${resolved.reason})`);
+ continue;
+ }
+ const path = resolved.path;
+ if (resolved.rescued) {
+ this.logger.info({ claimedPath, rescuedPath: path }, "ATTACH path rescued by basename");
+ }
+ try {
+ const sendRes = await this.sender.sendFile(jid, path);
+ if (!sendRes.ok) {
+ this.logger.warn({ path, reason: sendRes.reason }, "attach failed");
+ await this.send(jid, `(couldn't attach ${basename(path)}: ${sendRes.reason})`);
+ }
+ } catch (err) {
+ this.logger.warn({ err, path }, "attach exception");
+ }
+ }
+
+ // Execute Mac actions. Each action gets a concise confirmation
+ // line back to the chat (or a clear error). All wrapped in
+ // try/catch so one failing action doesn't kill the others.
+ for (const ev of calendarEvents) {
+ try {
+ const res = await this.macActions.createCalendarEvent(ev);
+ await this.send(jid, res.ok ? `📅 added to calendar — ${res.detail || ev.title}` : `(calendar failed: ${res.reason})`);
+ this.logger.info({ title: ev.title, ok: res.ok }, "calendar action");
+ } catch (err) {
+ this.logger.warn({ err, title: ev.title }, "calendar action exception");
+ }
+ }
+ for (const n of notes) {
+ try {
+ const res = await this.macActions.createNote(n);
+ await this.send(jid, res.ok ? `🗒 saved as a note — "${n.title}"` : `(note failed: ${res.reason})`);
+ this.logger.info({ title: n.title, ok: res.ok }, "note action");
+ } catch (err) {
+ this.logger.warn({ err, title: n.title }, "note action exception");
+ }
+ }
+ for (const m of mailDrafts) {
+ try {
+ const res = await this.macActions.createMailDraft(m);
+ await this.send(jid, res.ok ? `✉️ draft opened in Mail — review + send when ready` : `(mail draft failed: ${res.reason})`);
+ this.logger.info({ to: m.to, subject: m.subject, ok: res.ok }, "mail action");
+ } catch (err) {
+ this.logger.warn({ err, subject: m.subject }, "mail action exception");
+ }
+ }
+ }
+
// Heuristic: is this group message addressed to the owner? Same
// bar as WhatsApp's isOwnerTargeted — match by first name, full
// name, "@", and the literal "@you". iMessage doesn't have
@@ -884,6 +1413,8 @@ export class IMessageSession {
muted: this.muted,
paused: Object.fromEntries(this.pausedUntil),
monitoredChats: [...this.monitoredChats],
+ personalDocsEnabled: this.personalDocsEnabled,
+ killSwitch: this.killSwitch,
};
writeFileSync(this.stateFile, JSON.stringify(data, null, 2));
} catch (err) {
@@ -900,6 +1431,10 @@ export class IMessageSession {
if (typeof v === "number" && v > Date.now()) this.pausedUntil.set(k, v);
}
for (const c of data.monitoredChats ?? []) this.monitoredChats.add(c);
+ // Toggles default to safe values: docs ON, killswitch OFF.
+ // Only honor the persisted value when present.
+ if (typeof data.personalDocsEnabled === "boolean") this.personalDocsEnabled = data.personalDocsEnabled;
+ if (typeof data.killSwitch === "boolean") this.killSwitch = data.killSwitch;
} catch (err) {
this.logger.warn({ err }, "could not load bot state, starting fresh");
}
diff --git a/services/whatsapp-bridge/src/index.ts b/services/whatsapp-bridge/src/index.ts
index 35dd5013..ec93ddd1 100644
--- a/services/whatsapp-bridge/src/index.ts
+++ b/services/whatsapp-bridge/src/index.ts
@@ -25,7 +25,7 @@ import express, { type NextFunction, type Request, type Response } from "express
import cors from "cors";
import { createServer } from "http";
import { WebSocketServer } from "ws";
-import { readFileSync } from "fs";
+import { readFileSync, readdirSync, existsSync, statSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import { WhatsAppSession } from "./session.js";
@@ -190,22 +190,61 @@ app.get("/session/:tenantId/status", (req, res) => {
});
});
-// POST /session/:tenantId/start -- start a new session (triggers QR generation)
+// GET /session/:tenantId/has-creds -- does the on-disk auth dir hold
+// usable WhatsApp credentials? Dashboard uses this to choose between
+// "Reconnect" (creds exist → silent reconnect) and "Pair with QR"
+// (no creds → fresh pairing flow). Cheap: a single stat() call.
+app.get("/session/:tenantId/has-creds", (req, res) => {
+ const { tenantId } = req.params;
+ // Reject path-traversal — tenantId is always a UUID or short slug.
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(tenantId)) {
+ res.status(400).json({ error: "invalid tenantId" });
+ return;
+ }
+ const credsFile = join(process.cwd(), "auth_sessions", tenantId, "creds.json");
+ let hasCreds = false;
+ let credsAgeMs: number | null = null;
+ try {
+ if (existsSync(credsFile)) {
+ const stat = statSync(credsFile);
+ if (stat.size >= 32) {
+ hasCreds = true;
+ credsAgeMs = Date.now() - stat.mtimeMs;
+ }
+ }
+ } catch {}
+ res.json({ hasCreds, credsAgeMs, sessionActive: sessions.has(tenantId) });
+});
+
+// POST /session/:tenantId/start -- start (or resume) a session.
+//
+// Idempotent: if a session is already active and connected, return
+// the current status WITHOUT disconnecting. Tearing down a working
+// connection only to recreate it triggers WhatsApp pre-key drift +
+// makes the next outbound message lossy. Only when the session is
+// dead/missing do we instantiate a new one.
+//
+// To force a re-pair (different number / wiped creds), use /reset.
app.post("/session/:tenantId/start", async (req, res) => {
const { tenantId } = req.params;
const existing = sessions.get(tenantId);
if (existing) {
- await existing.disconnect();
+ const state = existing.getConnectionState();
+ if (state === "connected" || state === "connecting" || state === "starting" || state === "reconnecting") {
+ res.json({ status: state, message: "session already active", reused: true });
+ return;
+ }
+ // Stale or errored session — replace it.
+ await existing.disconnect().catch(() => {});
sessions.delete(tenantId);
}
const session = new WhatsAppSession(tenantId, logger);
sessions.set(tenantId, session);
-
session.start();
- res.json({ status: "starting", message: "QR code will be sent via WebSocket" });
+ res.json({ status: "starting", message: "session starting — reuse creds if available, else QR via WebSocket", reused: false });
});
// POST /session/:tenantId/disconnect -- disconnect and remove session
@@ -561,8 +600,51 @@ if (CONTROL_PLANE_URL && HEARTBEAT_TOKEN) {
// Start
// ---------------------------------------------------------------------------
+// Scan auth_sessions/ for tenants with persisted creds and auto-start
+// their sessions. Baileys reconnects silently using the on-disk creds —
+// no QR needed. This means the dashboard sees `connected` from the
+// first request, instead of `idle` + a "Pair with QR" CTA. Lets the
+// bridge survive code restarts as a true daemon.
+//
+// A tenant directory counts as "has creds" when it contains a
+// non-empty creds.json file (the canonical Baileys auth file).
+function autoResumeSessions(): void {
+ const authBase = join(process.cwd(), "auth_sessions");
+ if (!existsSync(authBase)) {
+ logger.info({ authBase }, "no auth_sessions dir yet — skipping auto-resume");
+ return;
+ }
+ let resumed = 0;
+ let skipped = 0;
+ for (const entry of readdirSync(authBase, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue;
+ const tenantId = entry.name;
+ const credsFile = join(authBase, tenantId, "creds.json");
+ if (!existsSync(credsFile)) { skipped++; continue; }
+ try {
+ const stat = statSync(credsFile);
+ if (stat.size < 32) { skipped++; continue; } // creds.json is always 200+ bytes
+ } catch { skipped++; continue; }
+ if (sessions.has(tenantId)) continue; // already started somehow
+ try {
+ const session = new WhatsAppSession(tenantId, logger);
+ sessions.set(tenantId, session);
+ session.start();
+ resumed++;
+ logger.info({ tenantId }, "auto-resumed WhatsApp session from disk");
+ } catch (err) {
+ logger.warn({ err, tenantId }, "auto-resume failed for tenant");
+ }
+ }
+ logger.info({ resumed, skipped, total: resumed + skipped }, "auto-resume scan done");
+}
+
server.listen(PORT, BIND, () => {
logger.info({ bind: BIND, port: PORT }, "WhatsApp bridge service started");
+ // Auto-resume runs AFTER listen so the bridge accepts requests
+ // immediately. Sessions reconnect in the background; their state
+ // flows out via the existing WebSocket broadcast.
+ autoResumeSessions();
});
// Graceful shutdown: close Baileys sockets before exit so WhatsApp sees a
diff --git a/services/whatsapp-bridge/src/session.ts b/services/whatsapp-bridge/src/session.ts
index 40a352d3..35cf7b53 100644
--- a/services/whatsapp-bridge/src/session.ts
+++ b/services/whatsapp-bridge/src/session.ts
@@ -32,12 +32,57 @@ import { reactionToAction, dispatchReaction } from "@lantern/bridge-core/reactio
import { scheduleDigest, defaultDigestConfig } from "@lantern/bridge-core/daily-digest";
import { OfflineMonitor, defaultOfflineMonitorConfig } from "@lantern/bridge-core/offline-monitor";
import { EmailMirror } from "@lantern/bridge-core/email-mirror";
+import {
+ PersonalDocs,
+ defaultPersonalDocsConfig,
+ looksLikeDocQuery,
+ extractAttachMarkers,
+} from "@lantern/bridge-core/personal-docs";
+import { MacActions, extractActionMarkers } from "@lantern/bridge-core/mac-actions";
+import { humanizeWithOffer, looksLikeConfirmation, type PendingOffer } from "@lantern/bridge-core/humanize";
+import { extname } from "path";
+
+// MIME map for sendDocument — WhatsApp's UI shows a file-type icon
+// based on this. Falls back to application/octet-stream for unknown.
+const MIME_FOR_EXT: Record = {
+ ".pdf": "application/pdf",
+ ".doc": "application/msword",
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ ".xls": "application/vnd.ms-excel",
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ ".ppt": "application/vnd.ms-powerpoint",
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ ".txt": "text/plain",
+ ".md": "text/markdown",
+ ".json": "application/json",
+ ".csv": "text/csv",
+ ".html": "text/html",
+ ".htm": "text/html",
+ ".rtf": "application/rtf",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".png": "image/png",
+ ".gif": "image/gif",
+ ".heic": "image/heic",
+ ".mp4": "video/mp4",
+ ".mov": "video/quicktime",
+ ".zip": "application/zip",
+};
import type { BotState } from "./types.js";
// Display name for the bot-owner used in attention prompts and log lines.
// Configurable so the classifier doesn't hardcode a single user's name.
const OWNER_NAME = process.env.LANTERN_OWNER_NAME || "the owner";
+// Normalize a WhatsApp JID for comparison: strip the optional
+// ":" suffix from the id portion. Incoming msg.key.remoteJid
+// usually omits the device, but multi-device sync can include it.
+function normWaJid(jid: string): string {
+ if (!jid) return "";
+ const [id, server] = jid.split("@");
+ return `${id.split(":")[0]}@${server || ""}`;
+}
+
// Conservative estimate of how long a Baileys-issued QR stays scannable
// before WhatsApp's server rotates it. Baileys re-emits a new QR string
// when the previous one expires, so this is purely a UI hint for the
@@ -127,6 +172,14 @@ export class WhatsAppSession {
private tenantId: string;
private logger: Logger;
private agent: AgentClient;
+ private docs: PersonalDocs | null = null;
+ private macActions: MacActions | null = null;
+ // Per-chat cache of the most recent offer (humanize follow-up).
+ // On next-turn "yes" we execute it deterministically — bypasses
+ // LLM hallucination where it claims an action happened without
+ // emitting a marker.
+ private pendingOffers: Map = new Map();
+ private static readonly OFFER_TTL_MS = 10 * 60_000;
private attention: AttentionClassifier;
private media: MediaHandler;
private personal: PersonalClient;
@@ -144,6 +197,21 @@ export class WhatsAppSession {
private authDir: string;
private reconnectTimer: NodeJS.Timeout | null = null;
private reconnectAttempts = 0;
+ // Decrypt-storm watchdog. When the Signal pre-key state corrupts
+ // (bridge killed mid-write, multi-process race, etc.), Baileys's
+ // libsignal logs continuous "failed to decrypt message" errors at
+ // level=50. The bridge reports "connected" but receives ZERO
+ // messages that reach messages.upsert because every inbound dies
+ // in decryption. We hook the logger to count these errors and
+ // force a socket-level reconnect when the storm crosses threshold.
+ // Reconnect renegotiates the Signal session with WhatsApp and
+ // clears most transient corruption without a full re-pair.
+ private decryptErrorCount = 0;
+ private decryptErrorWindowStart = Date.now();
+ private static readonly DECRYPT_STORM_THRESHOLD = 20;
+ private static readonly DECRYPT_STORM_WINDOW_MS = 60_000;
+ private selfHealing = false;
+ private lastSuccessfulInboundAt = 0;
// Lifecycle telemetry surfaced via getDiagnostics() and the WS
// `connection_state` event. lastError carries the most recent failure
// reason (close code, exception message) so the dashboard can show a
@@ -161,6 +229,13 @@ export class WhatsAppSession {
// Global kill switch. Toggled via self-chat `/bot off`/`/bot on`,
// via dashboard, or via REST.
private muted = false;
+ // Personal-docs Q&A toggle. Default ON. Owner toggles from
+ // self-chat ("docs on" / "docs off"). Persisted.
+ private personalDocsEnabled = true;
+ // Master kill switch — separate from `muted`. Survives restart.
+ // When ENGAGED the bridge ignores every inbound except a
+ // "kill switch off" command from the owner's self-chat.
+ private killSwitch = false;
private stateFile: string;
// Opted-in group JIDs. Groups not in this set are ignored entirely; in
// opted-in groups the agent runs the attention classifier on every message
@@ -218,6 +293,8 @@ export class WhatsAppSession {
this.media = new MediaHandler(this.logger);
this.personal = new PersonalClient(this.logger);
this.calendar = new CalendarLookup(this.logger);
+ this.docs = new PersonalDocs(defaultPersonalDocsConfig(this.authDir), this.logger);
+ this.macActions = new MacActions(this.logger);
// User preferences (monitored groups, paused contacts, mute) live
// OUTSIDE auth_sessions/ so `reset()` (which wipes auth creds for
// re-pair) doesn't also nuke the user's settings. Previously they
@@ -410,13 +487,41 @@ export class WhatsAppSession {
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
const { version } = await fetchLatestBaileysVersion();
+ // Wrap our pino logger with a thin proxy that counts decrypt
+ // errors. Baileys logs "failed to decrypt message" at level=50
+ // when libsignal can't find/use a session. A burst of these
+ // (>20 in 60s) means Signal state is corrupted — we force a
+ // socket-level reconnect to renegotiate, no QR re-pair needed.
+ const baseLogger = this.logger;
+ const baileysLogger = baseLogger.child({}) as Logger & {
+ error: (obj: unknown, msg?: string) => void;
+ };
+ const origError = baileysLogger.error.bind(baileysLogger);
+ baileysLogger.error = (obj: unknown, msg?: string) => {
+ let probe = "";
+ try {
+ const m = (typeof obj === "object" && obj && (obj as { msg?: string }).msg) || msg || "";
+ probe = String(m);
+ // Stringify is best-effort — Baileys logs can contain
+ // Buffers/circular refs that throw. Failure here MUST NOT
+ // break logging.
+ probe += " " + JSON.stringify(obj, (_k, v) => (v instanceof Error ? v.message : v));
+ } catch {
+ // Probe falls back to just the msg string
+ }
+ if (/failed to decrypt message|No matching sessions|MessageCounterError|Bad MAC/i.test(probe)) {
+ this.noteDecryptError();
+ }
+ origError(obj, msg);
+ };
+
this.socket = makeWASocket({
version,
auth: {
creds: state.creds,
- keys: makeCacheableSignalKeyStore(state.keys, this.logger),
+ keys: makeCacheableSignalKeyStore(state.keys, baileysLogger),
},
- logger: this.logger,
+ logger: baileysLogger,
browser: ["Lantern", "Desktop", "1.0.0"],
// Baileys' default (60s) spuriously trips fetchProps/history sync on
// slow networks; undefined disables the per-query timeout.
@@ -619,6 +724,28 @@ export class WhatsAppSession {
const from = msg.key.remoteJid || "";
if (!from) continue;
+ // KILL SWITCH gate. When engaged the bridge IGNORES
+ // everything except a "kill switch off" command from the
+ // owner's self-chat. We parse early — before bodyguard
+ // text-extraction logic — to keep the rejection path cheap.
+ if (this.killSwitch) {
+ const probe =
+ msg.message?.conversation
+ || msg.message?.extendedTextMessage?.text
+ || "";
+ const cmd = probe ? parseNLCommand(probe.trim()) : null;
+ const isOwnerChan = this.isOwnerChat(from);
+ const releasing = !!cmd && cmd.action === "killswitch-off";
+ // In self-chat mode, fromMe=true (owner authored it). In
+ // dedicated-bot mode the owner DMs the bot from another
+ // number → fromMe=false. Accept both.
+ if (!(isOwnerChan && releasing)) {
+ continue; // total silence
+ }
+ // Release: fall through to normal handling so the
+ // command executor fires + confirms back.
+ }
+
// Reaction commands. If the owner reacts to a message
// (typically a bot-sent reply) with a recognized emoji,
// we treat it as a command on that thread:
@@ -757,6 +884,19 @@ export class WhatsAppSession {
continue;
}
+ // DEDICATED-BOT MODE: owner DMs the bot from their primary
+ // WhatsApp number. msg.key.fromMe is false here (the bot's
+ // session didn't author it), but isOwnerChat(from) is true
+ // (the sender JID matches LANTERN_WA_OWNER_JID). Route the
+ // same owner-control + docs + commands path used in
+ // self-chat mode so all features (status, /bot off, doc
+ // queries, agentic actions) work identically.
+ if (!isGroup && text && this.isOwnerChat(from)) {
+ this.rememberOwnerSent(from, text);
+ await this.handleOwnerMessage(from, text, msg.key);
+ continue;
+ }
+
// Remember the sender's display name for later — we'll use it in
// the grace-period warning DM, where we only have the JID and no
// fresh message context. Non-DM JIDs (groups) are skipped; we
@@ -1113,8 +1253,13 @@ export class WhatsAppSession {
monitoredGroups?: string[];
disclosedJids?: string[];
ownerSentHistory?: Record;
+ personalDocsEnabled?: boolean;
+ killSwitch?: boolean;
};
this.muted = !!raw.muted;
+ // Toggles default to safe values: docs ON, killswitch OFF.
+ if (typeof raw.personalDocsEnabled === "boolean") this.personalDocsEnabled = raw.personalDocsEnabled;
+ if (typeof raw.killSwitch === "boolean") this.killSwitch = raw.killSwitch;
const now = Date.now();
for (const [jid, v] of Object.entries(raw.pausedUntil ?? {})) {
const entry = this.coercePauseEntry(v);
@@ -1185,6 +1330,8 @@ export class WhatsAppSession {
monitoredGroups: [...this.monitoredGroups],
disclosedJids: [...this.disclosedJids],
ownerSentHistory,
+ personalDocsEnabled: this.personalDocsEnabled,
+ killSwitch: this.killSwitch,
};
writeFileSync(this.stateFile, JSON.stringify(payload, null, 2));
} catch (err) {
@@ -1248,9 +1395,89 @@ export class WhatsAppSession {
return false;
}
+ // Increment decrypt-error counter; trigger self-heal when the
+ // storm crosses threshold inside the rolling window.
+ private noteDecryptError(): void {
+ const now = Date.now();
+ if (now - this.decryptErrorWindowStart > WhatsAppSession.DECRYPT_STORM_WINDOW_MS) {
+ this.decryptErrorWindowStart = now;
+ this.decryptErrorCount = 0;
+ }
+ this.decryptErrorCount++;
+ if (this.decryptErrorCount >= WhatsAppSession.DECRYPT_STORM_THRESHOLD && !this.selfHealing) {
+ this.selfHealing = true;
+ this.logger.warn(
+ { count: this.decryptErrorCount, windowMs: now - this.decryptErrorWindowStart },
+ "decrypt storm detected — forcing socket reconnect to renegotiate Signal state",
+ );
+ this.triggerSelfHeal().catch((err) =>
+ this.logger.error({ err }, "self-heal threw"),
+ );
+ }
+ }
+
+ // Force a socket-level reconnect WITHOUT wiping auth creds.
+ // Baileys will re-handshake with WhatsApp using existing creds,
+ // which usually renegotiates Signal pre-keys + clears transient
+ // session-state corruption. Distinct from /reset which wipes
+ // creds and requires a QR re-pair.
+ private async triggerSelfHeal(): Promise {
+ try {
+ // Close current socket — Baileys' connection.update handler
+ // already auto-reconnects with backoff (see the close handler
+ // around line 640+). After reconnect, decryptErrorCount resets
+ // when the next successful message lands.
+ this.socket?.ws?.close();
+ this.setConnectionState("reconnecting", "self-heal: decrypt storm");
+ // Email-mirror the user so they know recovery is in flight.
+ void this.mirrorToEmail?.("⚠️ WhatsApp bridge auto-recovery: Signal state was corrupted (20+ decrypt failures in 60s). Forcing socket reconnect — no QR needed. Should resume in <30s.").catch(() => {});
+ } catch (err) {
+ this.logger.error({ err }, "self-heal close failed");
+ } finally {
+ // Reset gate after a delay so a NEW storm can re-trigger.
+ setTimeout(() => {
+ this.selfHealing = false;
+ this.decryptErrorCount = 0;
+ this.decryptErrorWindowStart = Date.now();
+ }, 60_000);
+ }
+ }
+
private isSelfChat(jid: string): boolean {
- const own = this.ownJid();
- return !!own && jid === own;
+ if (!jid) return false;
+ const target = normWaJid(jid);
+ // ownIds() returns BOTH JID forms WhatsApp uses for the owner:
+ // - phone-format: "@s.whatsapp.net"
+ // - LID-format: "@lid" (newer privacy IDs)
+ for (const own of this.ownIds()) {
+ if (normWaJid(own) === target) return true;
+ }
+ return false;
+ }
+
+ // Owner-chat check — the SECURITY gate for personal-docs, agentic
+ // actions, the killswitch-release command, etc. Two topologies:
+ //
+ // (A) DEDICATED BOT MODE — bridge paired to a SEPARATE WhatsApp
+ // number (Google Voice / Twilio / spare SIM) acting as the
+ // bot. Owner DMs the bot from their primary WhatsApp.
+ // Set LANTERN_WA_OWNER_JID to the owner's primary JID,
+ // either "@s.whatsapp.net" or just "".
+ // (B) SELF-CHAT MODE — single number; owner messages themselves.
+ //
+ // Both accepted. Group chats are never owner-chats.
+ private isOwnerChat(jid: string): boolean {
+ if (!jid) return false;
+ if (this.isGroupJid(jid)) return false;
+ const target = normWaJid(jid);
+ // Mode A: explicit owner-JID env var
+ const ownerEnv = (process.env.LANTERN_WA_OWNER_JID || "").trim();
+ if (ownerEnv) {
+ const ownerJid = ownerEnv.includes("@") ? ownerEnv : `${ownerEnv.replace(/\D/g, "")}@s.whatsapp.net`;
+ if (normWaJid(ownerJid) === target) return true;
+ }
+ // Mode B: self-chat fallback
+ return this.isSelfChat(jid);
}
private async handleOwnerMessage(
@@ -1259,7 +1486,13 @@ export class WhatsAppSession {
key: { id?: string | null; remoteJid?: string | null; fromMe?: boolean | null; participant?: string | null }
) {
const trimmed = text.trim().toLowerCase();
- const self = this.isSelfChat(jid);
+ // `self` here means "the owner-control channel" — either the
+ // owner's own self-chat (single-account mode) OR a DM from the
+ // owner's primary number to this dedicated bot (LANTERN_WA_OWNER_JID).
+ // Both topologies route through the same command + docs paths
+ // since the semantics are identical: it's the owner addressing
+ // the bot directly.
+ const self = this.isOwnerChat(jid);
const group = this.isGroupJid(jid);
// Natural-language command parsing. Two contexts:
@@ -1299,6 +1532,31 @@ export class WhatsAppSession {
}
}
+ // Personal-docs Q&A — owner asking about local files in self-chat.
+ // SECURITY: triple-gated:
+ // 1) personalDocsEnabled toggle ON (owner-controlled, persisted)
+ // 2) `self` flag — message is in the owner's self-chat (jid
+ // matches the bridge's own paired number)
+ // 3) NOT a group
+ // No DM from a contact or group message can reach this code path.
+ // CONFIRMATION INTERCEPT: pending offer + user says "yes" →
+ // execute the action deterministically, no LLM round trip.
+ if (this.personalDocsEnabled && self && !group && this.docs && this.macActions) {
+ this.gcPendingOffers();
+ const cachedOffer = this.pendingOffers.get(jid);
+ if (cachedOffer && looksLikeConfirmation(text)) {
+ this.logger.info({ kind: cachedOffer.kind, jid }, "executing cached offer on confirmation");
+ this.pendingOffers.delete(jid);
+ void this.executeCachedOffer(jid, cachedOffer);
+ return;
+ }
+ }
+
+ if (this.personalDocsEnabled && self && !group && this.docs && looksLikeDocQuery(text)) {
+ void this.handleOwnerDocQuery(jid, text, key);
+ return;
+ }
+
// Group-scoped: /bot monitor on|off opts a group in / out of the agent.
if (group && trimmed === "/bot monitor on") {
this.monitorGroup(jid);
@@ -1590,7 +1848,8 @@ export class WhatsAppSession {
const phone = this.phoneNumber ? `+${this.phoneNumber}` : "(not paired)";
return [
`🟢 *Lantern WhatsApp*`,
- `• bot: ${this.muted ? "off" : "on"}`,
+ `• bot: ${this.killSwitch ? "🚨 KILL SWITCH ENGAGED" : this.muted ? "off" : "on"}`,
+ `• personal-docs: ${this.personalDocsEnabled ? "on" : "off"}`,
`• paired: ${phone}`,
`• paused contacts: ${pausedCount}`,
`• monitored groups: ${this.monitoredGroups.size}`,
@@ -1619,7 +1878,214 @@ export class WhatsAppSession {
this.saveState();
this.logActivity("bot_on", `Cleared ${n} per-contact pauses`, { scope: "self" });
},
+ setDocsEnabled: async (enabled: boolean) => {
+ this.personalDocsEnabled = enabled;
+ this.saveState();
+ this.logActivity(enabled ? "bot_on" : "bot_off", `personal-docs ${enabled ? "ENABLED" : "DISABLED"}`, { scope: "self" });
+ },
+ setKillSwitch: async (engaged: boolean) => {
+ this.killSwitch = engaged;
+ this.saveState();
+ this.logActivity(engaged ? "bot_off" : "bot_on", `🚨 kill switch ${engaged ? "ENGAGED" : "RELEASED"}`, { scope: "self" });
+ },
+ });
+ }
+
+ // Personal-docs Q&A in WhatsApp self-chat. Owner asked about a
+ // local file. Search → inject into LLM → reply → optionally attach
+ // the file via Baileys's document message type.
+ private async handleOwnerDocQuery(
+ jid: string,
+ query: string,
+ key: { id?: string | null; remoteJid?: string | null; fromMe?: boolean | null; participant?: string | null },
+ ): Promise {
+ if (!this.docs) return;
+ this.logger.info({ query: query.slice(0, 80) }, "owner doc query (whatsapp)");
+ this.logActivity("attention_dm", `📁 doc query: ${query.slice(0, 60)}`, { scope: "self" });
+ // Acknowledge so the user sees instant feedback. We do BOTH a
+ // reaction (subtle, on their message) AND a text ack (impossible
+ // to miss). Doc queries can take 10-15s for OCR'd PDFs and the
+ // reaction alone isn't loud enough.
+ try { await this.sendReaction(jid, key, "📁"); } catch {}
+ await this.confirmToSelf("📁 one sec — looking through your files…");
+
+ // If the heavy work runs long (>6s), nudge once so the user
+ // knows we're still chewing. Cancelled when the work finishes.
+ const startedAt = Date.now();
+ let progressFired = false;
+ const progressTimer = setTimeout(() => {
+ progressFired = true;
+ void this.confirmToSelf("📷 still scanning — almost there…");
+ }, 6000);
+ const clearProgress = () => {
+ clearTimeout(progressTimer);
+ if (progressFired) { /* already informed */ }
+ };
+
+ const contextBlock = await this.docs.buildContextBlock(query, { includeBodies: true });
+ this.logger.info({ ms: Date.now() - startedAt }, "doc context built (whatsapp)");
+ const today = new Date().toISOString().slice(0, 10);
+ const systemHint = [
+ `You are Lantern — Shekhar's personal agent, replying in his WhatsApp self-chat.`,
+ `Today is ${today}. You can search his Mac, read local files (incl. OCR scanned PDFs), and take native actions on his behalf.`,
+ ``,
+ `STYLE — sophisticated, natural, agentic. Like Jarvis: warm, concise, never robotic.`,
+ ` • Direct answers first. No "I'd be happy to" / "feel free".`,
+ ` • Lowercase, conversational. 1-3 short lines max.`,
+ ` • State the FACT directly when you have it. If the OCR'd content gives the answer, give it. Don't say "check the file".`,
+ ``,
+ `AGENTIC FOLLOW-UPS — MANDATORY when applicable:`,
+ ` • Answer mentions an EXPIRY/DUE DATE/DEADLINE → ALWAYS add a second line offering a calendar reminder.`,
+ ` Example: "want me to add a renewal reminder to your calendar 60 days before?"`,
+ ` • Answer mentions a NUMBER worth remembering (passport #, license #, account #) → offer to save it as a Note.`,
+ ` • Answer references a FILE the user might want → offer to attach it.`,
+ ` • If the answer is purely factual and none of the above apply, no offer is needed.`,
+ ``,
+ `ACTIONS — emit ONE marker per action on its own line at the END of your reply. The bridge executes them.`,
+ ` • Attach file: [ATTACH:/exact/absolute/path] — COPY paths VERBATIM from the context block. Never fabricate.`,
+ ` • Calendar event: [CALENDAR:Title|2026-08-19T09:00:00|2026-08-19T10:00:00|Optional notes] (local TZ, ISO format)`,
+ ` • Note: [NOTE:Title|Body text]`,
+ ` • Mail draft: [MAIL:to@x.com,b@y.com|Subject|Body] (opens draft in Mail.app for review)`,
+ ``,
+ `OFFER-then-CONFIRM rule: Don't fire an action on first mention. End reply with a short suggestion. If user confirms next turn ("yes", "sure", "do it"), THEN emit the marker.`,
+ ``,
+ `PATHS: Many files live under iCloud Drive at /Users/shakes/Library/Mobile Documents/com~apple~CloudDocs/... — never substitute /Users/shakes/Documents/...`,
+ ``,
+ contextBlock,
+ ].join("\n");
+
+ const draft = await this.agent.respondTo(jid, query, systemHint);
+ clearProgress();
+ this.logger.info({ totalMs: Date.now() - startedAt, hadDraft: !!draft }, "doc query done (whatsapp)");
+ if (!draft) {
+ await this.confirmToSelf("(couldn't reach the agent — try again in a sec.)");
+ return;
+ }
+
+ const { cleanedText: textNoAttach, paths } = extractAttachMarkers(draft);
+ const { cleanedText: finalText, calendarEvents, notes, mailDrafts } = extractActionMarkers(textNoAttach);
+ // Humanize: friendly dates + guaranteed offer + deterministic
+ // execution path on next-turn confirmation.
+ const { reply: polished, offer } = humanizeWithOffer(finalText);
+ if (polished) {
+ await this.confirmToSelf(polished);
+ }
+ if (offer && jid) {
+ this.pendingOffers.set(jid, offer);
+ }
+ for (const claimedPath of paths) {
+ const resolved = await this.docs.resolveAttachPath(claimedPath);
+ if (!resolved.ok) {
+ this.logger.warn({ claimedPath, reason: resolved.reason }, "ATTACH path unresolved — skipped");
+ await this.confirmToSelf(`(couldn't attach — ${resolved.reason})`);
+ continue;
+ }
+ const path = resolved.path;
+ if (resolved.rescued) {
+ this.logger.info({ claimedPath, rescuedPath: path }, "ATTACH path rescued by basename");
+ }
+ try {
+ await this.sendDocument(jid, path);
+ } catch (err) {
+ this.logger.warn({ err, path }, "WhatsApp doc send failed");
+ await this.confirmToSelf(`(couldn't attach ${path.split("/").pop() || path})`);
+ }
+ }
+ if (this.macActions) {
+ for (const ev of calendarEvents) {
+ try {
+ const res = await this.macActions.createCalendarEvent(ev);
+ await this.confirmToSelf(res.ok ? `📅 added to calendar — ${res.detail || ev.title}` : `(calendar failed: ${res.reason})`);
+ } catch (err) { this.logger.warn({ err }, "calendar action exception"); }
+ }
+ for (const n of notes) {
+ try {
+ const res = await this.macActions.createNote(n);
+ await this.confirmToSelf(res.ok ? `🗒 saved as a note — "${n.title}"` : `(note failed: ${res.reason})`);
+ } catch (err) { this.logger.warn({ err }, "note action exception"); }
+ }
+ for (const m of mailDrafts) {
+ try {
+ const res = await this.macActions.createMailDraft(m);
+ await this.confirmToSelf(res.ok ? `✉️ draft opened in Mail — review + send when ready` : `(mail draft failed: ${res.reason})`);
+ } catch (err) { this.logger.warn({ err }, "mail action exception"); }
+ }
+ }
+ }
+
+ // Execute a cached offer (calendar reminder / save note).
+ // Deterministic, bypasses the LLM. Sends a natural confirmation.
+ private async executeCachedOffer(jid: string, offer: PendingOffer): Promise {
+ if (!this.macActions) return;
+ if (offer.kind === "calendar-reminder" && offer.targetIsoDate && offer.leadDays) {
+ const target = new Date(`${offer.targetIsoDate}T09:00:00`);
+ const reminderDate = new Date(target.getTime() - offer.leadDays * 86_400_000);
+ const startIso = reminderDate.toISOString().slice(0, 19);
+ const endIso = new Date(reminderDate.getTime() + 30 * 60_000).toISOString().slice(0, 19);
+ try {
+ const res = await this.macActions.createCalendarEvent({
+ title: offer.title || "Renewal reminder",
+ start: startIso,
+ end: endIso,
+ notes: `Auto-set by Lantern. Original expiration: ${offer.targetIsoDate}.`,
+ });
+ if (res.ok) {
+ const friendly = reminderDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
+ await this.confirmToSelf(`📅 done — reminder set for ${friendly} (${offer.leadDays} days before). ${res.detail || ""}`);
+ } else {
+ await this.confirmToSelf(`(couldn't add to calendar: ${res.reason})`);
+ }
+ } catch (err) {
+ this.logger.error({ err }, "calendar offer execution failed");
+ await this.confirmToSelf(`(calendar add failed — try again)`);
+ }
+ return;
+ }
+ if (offer.kind === "save-note" && offer.noteTitle && offer.noteBody) {
+ try {
+ const res = await this.macActions.createNote({
+ title: offer.noteTitle,
+ body: offer.noteBody,
+ });
+ if (res.ok) {
+ await this.confirmToSelf(`🗒 saved as a note — "${offer.noteTitle}". find it in Notes.app.`);
+ } else {
+ await this.confirmToSelf(`(couldn't save the note: ${res.reason})`);
+ }
+ } catch (err) {
+ this.logger.error({ err }, "note offer execution failed");
+ await this.confirmToSelf(`(note save failed — try again)`);
+ }
+ return;
+ }
+ }
+
+ // GC pendingOffers — sweep entries older than OFFER_TTL_MS.
+ private gcPendingOffers(): void {
+ if (this.pendingOffers.size === 0) return;
+ const cutoff = Date.now() - WhatsAppSession.OFFER_TTL_MS;
+ for (const [key, offer] of this.pendingOffers) {
+ if (offer.issuedAt < cutoff) this.pendingOffers.delete(key);
+ }
+ }
+
+ // Send a local file as a WhatsApp document attachment. Uses
+ // Baileys's document message type. Attaches a sensible mimetype
+ // based on extension.
+ private async sendDocument(jid: string, filePath: string): Promise {
+ if (!this.socket || !this.connected) {
+ throw new Error("WhatsApp not connected");
+ }
+ const data = readFileSync(filePath);
+ const ext = extname(filePath).toLowerCase();
+ const mime = MIME_FOR_EXT[ext] || "application/octet-stream";
+ const fileName = filePath.split("/").pop() || "file";
+ const sent = await this.socket.sendMessage(jid, {
+ document: data,
+ mimetype: mime,
+ fileName,
});
+ if (sent?.key?.id) this.bridgeSentIds.set(sent.key.id, Date.now());
}
private async deleteCommand(
From 8b55b7f2e1ab55e1955765429ff1e3eb6a114966 Mon Sep 17 00:00:00 2001
From: Shekhar Mudarapu
Date: Mon, 25 May 2026 14:21:39 -0400
Subject: [PATCH 017/742] Owner-channel natural chat: bridges respond to
free-form messages too
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a Jarvis-style natural-reply fallback in both bridges. After the
killswitch / command / doc-query / confirmation paths fail to match,
the owner-channel routes the message to the regular agent for a free-
form reply. "Hi" now gets a hello back instead of silent drop.
* iMessage + WhatsApp: new handleOwnerNaturalChat with time-of-day,
owner name, and persona-tuned system prompt (warm, concise, peer
not corporate). Owner-channel-only — never replies in groups, never
on contact DMs.
* Uses the SAME agent (imessage-assistant / whatsapp-assistant) the
rest of the bridge uses, so any connectors attached in the
dashboard (Gmail, Calendar, MCP servers, etc.) are available
during chat too.
* Routing order in owner channel: confirmation > doc-query >
doc-followup > natural chat. Personal-docs path still takes
precedence so factual queries don't get small-talk replies.
* New env: LANTERN_OWNER_CHAT_NL=off to silence the natural-reply
fallback for users who treat their self-chat as a scratchpad.
---
services/imessage-bridge/src/session.ts | 49 +++++++++++++++++++++++++
services/whatsapp-bridge/src/session.ts | 43 ++++++++++++++++++++++
2 files changed, 92 insertions(+)
diff --git a/services/imessage-bridge/src/session.ts b/services/imessage-bridge/src/session.ts
index 259f35f6..c598051d 100644
--- a/services/imessage-bridge/src/session.ts
+++ b/services/imessage-bridge/src/session.ts
@@ -766,6 +766,18 @@ export class IMessageSession {
void this.handleOwnerDocQuery(row.handle, text, { followup: isFollowup });
return;
}
+
+ // OWNER-CHANNEL NATURAL CHAT: free-form messages from the owner
+ // that aren't commands/docs/confirmations. Route to the regular
+ // agent so the bridge functions as a chatbot on top of the
+ // command + docs layers. Suppressed when LANTERN_OWNER_CHAT_NL=off
+ // for users who treat their self-chat as a silent scratchpad.
+ const nlEnabled = (process.env.LANTERN_OWNER_CHAT_NL || "on").toLowerCase() !== "off";
+ if (nlEnabled && !this.muted) {
+ this.logger.info({ chat: row.handle, textPreview: text.slice(0, 60) }, "owner natural chat");
+ void this.handleOwnerNaturalChat(row.handle, text);
+ return;
+ }
}
// Media: attach a synthetic-text annotation when an attachment is
@@ -1138,6 +1150,43 @@ export class IMessageSession {
}
}
+ // Owner free-form chat — anything that isn't a command, doc query,
+ // or confirmation. Lets the owner-channel double as a Jarvis-style
+ // chatbot on top of the personal-docs / actions layer.
+ //
+ // Tone: short, warm, lowercase — matches the Jarvis persona used
+ // for doc queries. We DON'T inject the docs context block here
+ // (no search runs), so this is just a clean agent round-trip.
+ private async handleOwnerNaturalChat(jid: string, text: string): Promise {
+ const today = new Date().toISOString().slice(0, 10);
+ const hour = new Date().getHours();
+ const timeOfDay = hour < 5 ? "late night" : hour < 12 ? "morning" : hour < 17 ? "afternoon" : hour < 22 ? "evening" : "late night";
+ const ownerName = (process.env.LANTERN_OWNER_NAME || "Shekhar").split(/\s+/)[0];
+ const systemHint = [
+ `You are Lantern — ${ownerName}'s personal agent, replying in his iMessage self-chat.`,
+ `Today is ${today}. Local time of day: ${timeOfDay}.`,
+ ``,
+ `You ARE his Jarvis. Warm, concise, authentic. Like a sharp peer who knows him well.`,
+ ` • 1-3 short lines. No corporate filler ("I'd be happy to" / "feel free" / "let me know if").`,
+ ` • Lowercase, conversational.`,
+ ` • Match his energy — if he's brief, you're brief.`,
+ ` • For greetings ("hi", "hey"), say hello briefly + ask what he needs OR drop a useful nudge from time of day (morning → "anything on for today?", evening → "wrap-up notes for tomorrow?").`,
+ ` • You can search his Mac files (passport, license, receipts, etc.) — when he mentions one, suggest the exact phrasing ("when does my passport expire", "find my I-485 receipt", "what's my green card number").`,
+ ` • You can add calendar events, save notes, draft mail on his behalf — offer when relevant.`,
+ ` • Use any connector tools attached to this agent in the Lantern dashboard (Gmail, Calendar, etc.) when helpful.`,
+ ].join("\n");
+ try {
+ const draft = await this.agent.respondTo(jid, text, systemHint);
+ if (!draft) {
+ this.logger.warn({ jid }, "owner natural chat — no draft");
+ return;
+ }
+ await this.send(jid, draft.trim());
+ } catch (err) {
+ this.logger.warn({ err, jid }, "owner natural chat exception");
+ }
+ }
+
// GC pendingOffers Map — sweep entries older than OFFER_TTL_MS so
// a long-lived session can't accumulate stale offers indefinitely.
// Called lazily on each confirmation-intercept check (cheap O(n)).
diff --git a/services/whatsapp-bridge/src/session.ts b/services/whatsapp-bridge/src/session.ts
index 35cf7b53..296f807f 100644
--- a/services/whatsapp-bridge/src/session.ts
+++ b/services/whatsapp-bridge/src/session.ts
@@ -1557,6 +1557,19 @@ export class WhatsAppSession {
return;
}
+ // OWNER-CHANNEL NATURAL CHAT: free-form chat from the owner that
+ // isn't a command, doc query, or confirmation. Routes to the
+ // regular agent so the bridge functions as a chatbot in addition
+ // to commands + docs. Suppressed when LANTERN_OWNER_CHAT_NL=off.
+ if (self && !group && text) {
+ const nlEnabled = (process.env.LANTERN_OWNER_CHAT_NL || "on").toLowerCase() !== "off";
+ if (nlEnabled && !this.muted) {
+ this.logger.info({ jid, textPreview: text.slice(0, 60) }, "owner natural chat");
+ void this.handleOwnerNaturalChat(jid, text);
+ return;
+ }
+ }
+
// Group-scoped: /bot monitor on|off opts a group in / out of the agent.
if (group && trimmed === "/bot monitor on") {
this.monitorGroup(jid);
@@ -2060,6 +2073,36 @@ export class WhatsAppSession {
}
}
+ // Owner free-form chat handler. Same shape as iMessage's — agent
+ // round-trip with a Jarvis-tuned prompt so the owner channel
+ // doubles as a chatbot on top of commands + docs.
+ private async handleOwnerNaturalChat(jid: string, text: string): Promise {
+ const today = new Date().toISOString().slice(0, 10);
+ const hour = new Date().getHours();
+ const timeOfDay = hour < 5 ? "late night" : hour < 12 ? "morning" : hour < 17 ? "afternoon" : hour < 22 ? "evening" : "late night";
+ const ownerName = (process.env.LANTERN_OWNER_NAME || "Shekhar").split(/\s+/)[0];
+ const systemHint = [
+ `You are Lantern — ${ownerName}'s personal agent, replying in his WhatsApp self-chat.`,
+ `Today is ${today}. Local time of day: ${timeOfDay}.`,
+ ``,
+ `You ARE his Jarvis. Warm, concise, authentic. Like a sharp peer who knows him well.`,
+ ` • 1-3 short lines. No corporate filler ("I'd be happy to" / "feel free" / "let me know if").`,
+ ` • Lowercase, conversational.`,
+ ` • Match his energy — if he's brief, you're brief.`,
+ ` • For greetings ("hi", "hey"), say hello briefly + ask what he needs OR drop a useful nudge from time of day (morning → "anything on for today?", evening → "wrap-up notes for tomorrow?").`,
+ ` • You can search his Mac files (passport, license, receipts, etc.) — when he mentions one, suggest the exact phrasing ("when does my passport expire", "find my I-485 receipt", "what's my green card number").`,
+ ` • You can add calendar events, save notes, draft mail on his behalf — offer when relevant.`,
+ ` • Use any connector tools attached to this agent in the Lantern dashboard (Gmail, Calendar, etc.) when helpful.`,
+ ].join("\n");
+ try {
+ const draft = await this.agent.respondTo(jid, text, systemHint);
+ if (!draft) return;
+ await this.confirmToSelf(draft.trim());
+ } catch (err) {
+ this.logger.warn({ err, jid }, "owner natural chat exception (whatsapp)");
+ }
+ }
+
// GC pendingOffers — sweep entries older than OFFER_TTL_MS.
private gcPendingOffers(): void {
if (this.pendingOffers.size === 0) return;
From b0ec9f612b66e2d1b6eccefac2866c3cb18f349b Mon Sep 17 00:00:00 2001
From: Shekhar Mudarapu
Date: Mon, 25 May 2026 14:43:30 -0400
Subject: [PATCH 018/742] Owner natural chat: opt INTO connector tools
(Gmail/Calendar/etc.)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bridge previously sent noTools=true on every session message to dodge
the 30k-token blowup from auto-attaching all installed connectors.
Side-effect: when the owner asked "what's in my inbox" via natural
chat, the agent couldn't reach Gmail.
* AgentClient.respondTo gains optional { withTools: true } that
flips the noTools flag.
* Both bridges' handleOwnerNaturalChat opt in, so Gmail / Calendar /
Sheets / MCP servers attached to the bridge's agent are callable
from owner self-chat.
* Doc-query + agentic-action paths still send noTools=true — OCR
context is already large; no need to also load tool schemas.
Verified end-to-end: "how many unread emails" → 50 (real count via
gmail_list tool).
---
packages/bridge-core/src/agent.ts | 26 +++++++++++++++----------
services/imessage-bridge/src/session.ts | 2 +-
services/whatsapp-bridge/src/session.ts | 2 +-
3 files changed, 18 insertions(+), 12 deletions(-)
diff --git a/packages/bridge-core/src/agent.ts b/packages/bridge-core/src/agent.ts
index aa8c1b48..6db4e5aa 100644
--- a/packages/bridge-core/src/agent.ts
+++ b/packages/bridge-core/src/agent.ts
@@ -43,10 +43,16 @@ export class AgentClient {
enabled(): boolean { return authEnabled(); }
- async respondTo(jid: string, userText: string, systemHint?: string): Promise