diff --git a/packages/bridge-core/src/live-watch.test.ts b/packages/bridge-core/src/live-watch.test.ts index 69b8ec4e..ecf2162c 100644 --- a/packages/bridge-core/src/live-watch.test.ts +++ b/packages/bridge-core/src/live-watch.test.ts @@ -15,6 +15,8 @@ import { extractJson, WatchStore, type LiveWatch, + watchContextBlock, + contactWatchBlock, } from "./live-watch.ts"; const NOW = 1_783_120_000_000; @@ -161,3 +163,53 @@ test("WatchStore persists, dedupes per-jid, expires, prunes", () => { s2.update({ ...w, status: "done" }); assert.equal(s2.hasActive("j1", NOW), false); }); + +// Regression: the owner asked "track above flight" 70 minutes after the bot +// announced it was watching that exact flight, and got "I don't see a flight +// in our conversation". The announcement is bot-self text, so history filters +// it out β€” the store must reach the prompt directly. +test("watchContextBlock surfaces active watches (and only those)", () => { + const base = { + jid: "1@lid", topic: "United DFWβ†’IAD Aug 23, seat 38F", + searchQuery: "q", doneCondition: "the flight has landed at IAD", + createdTs: NOW, nextCheckTs: NOW, intervalMs: 60_000, + expiresTs: NOW + 3_600_000, checks: 3, contactName: "Sowmyadhar G", + lastSummary: "no date-specific status found yet", + }; + + const block = watchContextBlock([{ ...base, id: "a", status: "active" }], NOW); + assert.match(block, /United DFWβ†’IAD/); + assert.match(block, /Sowmyadhar G/); + assert.match(block, /3 check\(s\)/); + assert.match(block, /no date-specific status/); // last check must carry over + assert.match(block, /NEVER say you don't see it/); + + // Resolved, failed, and expired watches are not live commitments. + assert.equal(watchContextBlock([{ ...base, id: "b", status: "done" }], NOW), ""); + assert.equal( + watchContextBlock([{ ...base, id: "c", status: "active" }], NOW + 7_200_000), + "", + ); + assert.equal(watchContextBlock([], NOW), ""); +}); + +// The contact-facing twin. Isolation is the load-bearing property: a watch +// started off ANOTHER contact's message must never appear in this thread. +test("contactWatchBlock is scoped to one thread", () => { + const mk = (id: string, jid: string, topic: string) => ({ + id, jid, topic, searchQuery: "q", doneCondition: "it lands", + createdTs: NOW, nextCheckTs: NOW, intervalMs: 60_000, + expiresTs: NOW + 3_600_000, checks: 1, status: "active" as const, + }); + const all = [mk("a", "sowmya@lid", "UA flight to IAD"), mk("b", "raju@lid", "raju's package")]; + + const mine = contactWatchBlock(all, "sowmya@lid", NOW); + assert.match(mine, /UA flight to IAD/); + assert.doesNotMatch(mine, /package/); // no cross-contact leak + assert.match(mine, /never act surprised/); + + assert.match(contactWatchBlock(all, "raju@lid", NOW), /package/); + assert.equal(contactWatchBlock(all, "nobody@lid", NOW), ""); + // Expired commitments are not live promises. + assert.equal(contactWatchBlock(all, "sowmya@lid", NOW + 7_200_000), ""); +}); diff --git a/packages/bridge-core/src/live-watch.ts b/packages/bridge-core/src/live-watch.ts index 1338d70e..7ede6abb 100644 --- a/packages/bridge-core/src/live-watch.ts +++ b/packages/bridge-core/src/live-watch.ts @@ -270,3 +270,62 @@ function numberOr(v: unknown, d: number): number { function clamp(n: number, lo: number, hi: number): number { return Math.min(hi, Math.max(lo, n)); } + +/** + * Render the owner's ACTIVE watches as a prompt block. + * + * Without this the assistant is blind to its own commitments: it announces + * "πŸ“‘ watching: " to self-chat, that announcement is then filtered + * back out of history by `isBotSelfMessage`, and the next "track that flight" + * is answered with "I don't see a flight in our conversation" β€” denying work + * it is actively doing. The store is the source of truth, so read it directly + * rather than hoping the transcript carries it. + */ +export function watchContextBlock( + watches: readonly LiveWatch[], + now: number = Date.now(), +): string { + const active = watches.filter((w) => w.status === "active" && w.expiresTs > now); + if (!active.length) return ""; + const lines = active.map((w) => { + const who = w.contactName ? ` (for ${w.contactName})` : ""; + // lastSummary is the only field that says what the last check actually + // found; without it the model re-announces the topic as if check 1 of N. + const last = w.lastSummary ? ` β€” last check: ${w.lastSummary}` : ""; + return `- ${w.topic}${who}: watching until ${w.doneCondition}. ${w.checks} check(s) so far${last}`; + }); + return [ + "## Things you are ALREADY tracking (live watches you started)", + ...lines, + "These are YOUR active commitments. If the owner refers to \"that flight\", \"the above\", \"it\", or asks you to track/check something you already watch, THIS is what they mean β€” answer from it. NEVER say you don't see it or ask which one when exactly one matches.", + ].join("\n"); +} + +/** + * Contact-scoped twin of `watchContextBlock`, for the CONTACT reply prompt. + * + * Only this thread's watches are rendered β€” a watch started off another + * contact's message is that contact's business and must never surface here. + * The wording is deliberately different from the owner block: to the contact + * the bot IS the owner, so it must read as "I said I'd check on this", never + * as an assistant reporting on a task. + */ +export function contactWatchBlock( + watches: readonly LiveWatch[], + jid: string, + now: number = Date.now(), +): string { + const mine = watches.filter( + (w) => w.jid === jid && w.status === "active" && w.expiresTs > now, + ); + if (!mine.length) return ""; + const lines = mine.map((w) => { + const last = w.lastSummary ? ` Last time you checked: ${w.lastSummary}` : ""; + return `- ${w.topic} β€” you said you'd follow up once ${w.doneCondition}.${last}`; + }); + return [ + "## You already offered to keep an eye on this (for THIS person)", + ...lines, + "You committed to this in this thread. If they ask about it, answer from the above β€” never act surprised, never ask which thing they mean, and never re-offer to start tracking something you are already tracking. If you have nothing new yet, say so plainly.", + ].join("\n"); +} diff --git a/packages/bridge-core/src/natural-audit-fixes.test.ts b/packages/bridge-core/src/natural-audit-fixes.test.ts index 50e89b9c..317c500c 100644 --- a/packages/bridge-core/src/natural-audit-fixes.test.ts +++ b/packages/bridge-core/src/natural-audit-fixes.test.ts @@ -82,3 +82,29 @@ test("detectBotTells does not touch location claims on the owner channel", () => test("location fabrication net ignores non-location text", () => { assert.equal(detectBotTells("sounds good, talk later", "ok", { audience: "contact" }).ok, true); }); + +// AI_TELL_WORDS matched as a raw SUBSTRING, so ordinary human drafts were +// suppressed: "as personal as it gets" tripped "as per", "kindlyn said she'd +// come" tripped "kindly". A suppressed draft falls through to the canned +// greeting table or to silence β€” so a substring collision costs a real reply. +test("AI tell-words match on word boundaries, not substrings", () => { + for (const human of [ + "as personal as it gets honestly", + "kindlyn said she'd come", + "she's a great navigator on road trips", + ]) { + assert.equal(detectBotTells(human, "hey").ok, true, `suppressed human text: ${human}`); + } + + // The guard must still do its job on the real thing, including inflections. + for (const tell of [ + "kindly let me know your availability", + "let me delve into that for you", + "rest assured it will be handled", + "as per our discussion earlier", + "i will facilitate the process", + "he delved into it already", + ]) { + assert.equal(detectBotTells(tell, "hey").ok, false, `leaked AI tell: ${tell}`); + } +}); diff --git a/packages/bridge-core/src/natural.ts b/packages/bridge-core/src/natural.ts index c5b764c4..e2a70c1a 100644 --- a/packages/bridge-core/src/natural.ts +++ b/packages/bridge-core/src/natural.ts @@ -380,6 +380,12 @@ export interface PersonaOptions { // crossing tenant boundaries. The block tells the LLM not to // volunteer cross-thread details unless asked. relatedBlock?: string; + /** What the bot is ALREADY doing for THIS contact β€” active live watches it + * started off this thread. Without it the bot promises a follow-up, works + * on it in the background, and then answers this contact as if none of it + * ever happened. Contact-scoped by construction: only watches whose jid is + * this thread's are ever passed in. */ + selfContextBlock?: string; // Proactive recall block (from recall.ts β†’ assembleRelevantRecall). // The single most-relevant memory β€” an episode, a cross-thread topic, // an open commitment β€” selected by token-overlap with the current @@ -957,6 +963,16 @@ export function agentPersonaPrompt( lines.push(related); } + // What the bot is already doing FOR THIS CONTACT. It announces "I'll follow + // up when it resolves", then its own announcement is filtered out of history + // as bot-self text β€” so without this the next message in the same thread is + // answered with no idea the commitment exists. + const selfContext = opts.selfContextBlock?.trim(); + if (selfContext) { + lines.push(``); + lines.push(selfContext); + } + // Proactive recall β€” the single most-relevant memory for this inbound. // Present only when LANTERN_PROACTIVE_RECALL=1 and a relevant item was found; // null/absent β†’ prompt is identical to today (additive, never changes default). @@ -1636,6 +1652,19 @@ const AI_TELL_WORDS = [ "i hope this finds", ]; +// Word-boundary matcher for AI_TELL_WORDS. Cached because detectBotTells runs +// on every draft and the list is scanned in full each time. +const aiTellReCache = new Map(); +function aiTellRe(word: string): RegExp { + let re = aiTellReCache.get(word); + if (!re) { + const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + re = new RegExp(`\\b${escaped}(?:'?s|d|ed|ing)?\\b`, "i"); + aiTellReCache.set(word, re); + } + return re; +} + // Coherence guard (A4): an inbound that reports ARRIVAL / COMPLETION // ("reached", "landed", "got home", "done", "safe") is closing a loop, not // opening a plan. If the draft answers it with a FUTURE COMMITMENT ("see @@ -2034,7 +2063,13 @@ export function detectBotTells( } const lowered = text.toLowerCase(); for (const w of AI_TELL_WORDS) { - if (lowered.includes(w)) + // Word-boundary, NOT substring: `includes("as per")` suppressed "as + // personal as it gets" and `includes("kindly")` suppressed "kindlyn said + // she'd come" β€” real human drafts killed, and a suppressed draft falls + // through to the canned greeting table or silence. Escape the needle (it + // may contain `-`) and allow an apostrophe-suffix so "delve"/"delved" and + // possessives still catch. + if (aiTellRe(w).test(lowered)) return { ok: false, reason: `AI tell-word "${w}"` }; } diff --git a/packages/bridge-core/src/personal.ts b/packages/bridge-core/src/personal.ts index bc6cb996..b1567d1c 100644 --- a/packages/bridge-core/src/personal.ts +++ b/packages/bridge-core/src/personal.ts @@ -276,7 +276,22 @@ export class PersonalClient { opts: { windowDays?: number } = {}, ): Promise { const windowDays = opts.windowDays ?? PersonalClient.DEFAULT_WINDOW_DAYS; - const ctx = await this.unifiedContext(channel, handle, inboundText, { windowDays }); + // RELEVANCE and RECENCY are different questions and must not share a + // window. Previously one windowed request served both, so semantic recall + // could only ever match inside the last `windowDays` β€” measured on this + // deployment that hid 1,622 of 1,850 chat events (88%) from retrieval, no + // matter how relevant. A contact asking about something from last month + // got "what's that?" while the answer sat embedded and unreachable. + // + // So: the query pass runs UNWINDOWED (relevance decides, not age), and the + // recency slice keeps its window (that block literally says "last N days" + // and would be a lie otherwise). Two cheap reads instead of one crippled + // one; both already share the same per-person cache path. + const [relevantCtx, recentCtx] = await Promise.all([ + inboundText ? this.unifiedContext(channel, handle, inboundText) : Promise.resolve(null), + this.unifiedContext(channel, handle, undefined, { windowDays }), + ]); + const ctx = relevantCtx ?? recentCtx; if (!ctx) return this.factsBlock(handle); let block = ""; @@ -290,8 +305,12 @@ export class PersonalClient { // channels. Prefer the control-plane's `recent` field; if it isn't // exposed yet, derive it from `events` by filtering on occurredAt so the // behavior is identical regardless of which side computes the window. + // Recency comes from the WINDOWED read; the relevance read is unwindowed + // by design and its hits may be months old, which would make the "last N + // days" header false. + const recentBase = recentCtx ?? ctx; const cutoff = Date.now() - windowDays * 86_400_000; - const recentSrc = (ctx.recent ?? ctx.events ?? []).filter((e) => { + const recentSrc = (recentBase.recent ?? recentBase.events ?? []).filter((e) => { const t = Date.parse(e.occurredAt || ""); return Number.isNaN(t) ? true : t >= cutoff; }); @@ -305,6 +324,31 @@ export class PersonalClient { }).join("\n"); } + // MOST RELEVANT β€” hybrid (vector + lexical, RRF-fused) hits for THIS + // inbound, at any age. Deliberately separate from the recency block and + // labelled with its date, because "we talked about this in June" is a + // different claim from "we talked about this yesterday" and the model must + // not blur them. Rows already inside the recency window are dropped so the + // same line never appears twice. + if (relevantCtx) { + const shownRecently = new Set(recent.map((e) => (e.content || "").slice(0, 140))); + const relevant = (relevantCtx.events ?? []) + .filter((e) => { + const t = Date.parse(e.occurredAt || ""); + return !Number.isNaN(t) && t < cutoff; + }) + .filter((e) => !shownRecently.has((e.content || "").slice(0, 140))) + .slice(0, 4); + if (relevant.length > 0) { + block += `\n\nOlder, but relevant to what they just said (don't assume it's current β€” check if it still stands):\n` + + relevant.map((e) => { + const who = e.direction === "out" ? "you" : "them"; + const ch = e.channel && e.channel !== channel ? ` on ${e.channel}` : ""; + return `- [${fmtDate(e.occurredAt)}] ${who}${ch}: ${(e.content || "").slice(0, 140)}`; + }).join("\n"); + } + } + // Recent timeline from OTHER channels β€” the current thread is already // in the live transcript, so the value-add is what happened elsewhere. const other = (ctx.events ?? []) diff --git a/services/control-plane/internal/handlers/identity.go b/services/control-plane/internal/handlers/identity.go index 19dd3907..0688709c 100644 --- a/services/control-plane/internal/handlers/identity.go +++ b/services/control-plane/internal/handlers/identity.go @@ -78,6 +78,25 @@ func embedConcurrency() int { return 4 } +// candidatePool is how deep each retrieval arm ranks before fusion. +// +// Fusing two lists that are each only `limit` long is close to pointless: a row +// ranked just past the cutoff in BOTH arms β€” the strongest possible hybrid +// signal β€” would be invisible to the fusion. Over-fetching per arm is what lets +// agreement surface. 5x with a floor of 50 keeps the pool meaningful for the +// small limits the bridge uses (12) and bounded for large ones; both arms are +// index-ordered and capped, so the cost is a wider LIMIT, not a scan. +func candidatePool(limit int) int { + pool := limit * 5 + if pool < 50 { + pool = 50 + } + if pool > 500 { + pool = 500 + } + return pool +} + // embedAsync computes + stores the embedding for an event off the request // path, capped by embedSem. Best-effort: a failed embed leaves the row // searchable by recency/keyword and is retried later by backfillEmbeddings. @@ -597,21 +616,93 @@ func (h *IdentityHandler) GetContext(w http.ResponseWriter, r *http.Request) { usedVector := false if keyword != "" && h.llm != nil { if vec, embErr := h.llm.EmbedText(ctx, claims.TenantID, keyword); embErr == nil { + // HYBRID retrieval, fused with Reciprocal Rank Fusion. + // + // Vector similarity alone is weak on exactly what personal chat is + // made of: names, dates, order numbers, flight numbers β€” a + // paraphrase-tolerant embedding is paraphrase-tolerant about + // "UA2116" too. Lexical alone misses "decorations" -> "balloon + // decor". Previously these were either/or: keyword ran ONLY as a + // fallback when embedding failed, so a query that needed both got + // whichever one happened to win, never the union. + // + // RRF fuses by RANK, not score, which is the point: cosine + // distance (0..2) and ts_rank (unbounded) are not comparable, and + // normalizing them requires a corpus-wide calibration we do not + // have. score = sum over lists of 1/(k + rank). k=60 is the + // standard constant from the original RRF paper; it damps the + // influence of any single list's top hit so one confident-but-wrong + // ranker cannot dominate the fused order. + // + // A row found by BOTH lists outranks a row found by either alone β€” + // which is the behaviour we actually want for "what did we decide + // about the decorations": semantically close AND lexically present. + // websearch_to_tsquery parses user text safely (no injection, no + // syntax errors on stray punctuation) and returns no rows rather + // than erroring on a junk query, so the vector arm still stands. + const rrfK = 60 if windowDays > 0 { querySQL = ` - SELECT channel, kind, COALESCE(direction, ''), content, occurred_at - FROM memory_events - WHERE tenant_id = $1 AND person_id = $2 AND embedding IS NOT NULL - AND occurred_at >= now() - ($5 || ' days')::interval - ORDER BY embedding <=> $3::vector LIMIT $4` - queryArgs = []any{claims.TenantID, personID, vectorLiteral(vec), limit, strconv.Itoa(windowDays)} + WITH vec AS ( + SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $3::vector) AS rnk + FROM memory_events + WHERE tenant_id = $1 AND person_id = $2 AND embedding IS NOT NULL + AND occurred_at >= now() - ($5 || ' days')::interval + LIMIT $6 + ), kw AS ( + SELECT id, ROW_NUMBER() OVER ( + ORDER BY ts_rank(to_tsvector('english', content), + websearch_to_tsquery('english', $7)) DESC + ) AS rnk + FROM memory_events + WHERE tenant_id = $1 AND person_id = $2 + AND occurred_at >= now() - ($5 || ' days')::interval + AND to_tsvector('english', content) @@ websearch_to_tsquery('english', $7) + LIMIT $6 + ), fused AS ( + SELECT id, SUM(w) AS score FROM ( + SELECT id, 1.0 / ($8 + rnk) AS w FROM vec + UNION ALL + SELECT id, 1.0 / ($8 + rnk) AS w FROM kw + ) u GROUP BY id + ) + SELECT m.channel, m.kind, COALESCE(m.direction, ''), m.content, m.occurred_at + FROM fused f JOIN memory_events m ON m.id = f.id + ORDER BY f.score DESC, m.occurred_at DESC LIMIT $4` + queryArgs = []any{ + claims.TenantID, personID, vectorLiteral(vec), limit, + strconv.Itoa(windowDays), candidatePool(limit), keyword, rrfK, + } } else { querySQL = ` - SELECT channel, kind, COALESCE(direction, ''), content, occurred_at - FROM memory_events - WHERE tenant_id = $1 AND person_id = $2 AND embedding IS NOT NULL - ORDER BY embedding <=> $3::vector LIMIT $4` - queryArgs = []any{claims.TenantID, personID, vectorLiteral(vec), limit} + WITH vec AS ( + SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $3::vector) AS rnk + FROM memory_events + WHERE tenant_id = $1 AND person_id = $2 AND embedding IS NOT NULL + LIMIT $5 + ), kw AS ( + SELECT id, ROW_NUMBER() OVER ( + ORDER BY ts_rank(to_tsvector('english', content), + websearch_to_tsquery('english', $6)) DESC + ) AS rnk + FROM memory_events + WHERE tenant_id = $1 AND person_id = $2 + AND to_tsvector('english', content) @@ websearch_to_tsquery('english', $6) + LIMIT $5 + ), fused AS ( + SELECT id, SUM(w) AS score FROM ( + SELECT id, 1.0 / ($7 + rnk) AS w FROM vec + UNION ALL + SELECT id, 1.0 / ($7 + rnk) AS w FROM kw + ) u GROUP BY id + ) + SELECT m.channel, m.kind, COALESCE(m.direction, ''), m.content, m.occurred_at + FROM fused f JOIN memory_events m ON m.id = f.id + ORDER BY f.score DESC, m.occurred_at DESC LIMIT $4` + queryArgs = []any{ + claims.TenantID, personID, vectorLiteral(vec), limit, + candidatePool(limit), keyword, rrfK, + } } usedVector = true } else { diff --git a/services/gateway/Cargo.lock b/services/gateway/Cargo.lock index 11793dfc..2c9cc957 100644 --- a/services/gateway/Cargo.lock +++ b/services/gateway/Cargo.lock @@ -403,7 +403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -569,9 +569,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "h2" -version = "0.4.13" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -761,7 +761,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1391,7 +1391,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -1428,7 +1428,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", "windows-sys 0.52.0", ] diff --git a/services/imessage-bridge/src/session.ts b/services/imessage-bridge/src/session.ts index fd2e41c2..d8aa71e8 100644 --- a/services/imessage-bridge/src/session.ts +++ b/services/imessage-bridge/src/session.ts @@ -299,7 +299,7 @@ import { verifyClaims } from "@lantern/bridge-core/verifiable-claims"; import { PresenceTracker } from "@lantern/bridge-core/presence"; import { computeHoldFromSamples } from "@lantern/bridge-core/pacing"; import { EpisodicMemory, formatEpisodesBlock, maybeRecordEpisode, rankEpisodesByRelevance } from "@lantern/bridge-core/episodic-memory"; -import { detectLiveWatch, checkLiveWatch, composeWatchFollowUp, WatchStore } from "@lantern/bridge-core/live-watch"; +import { detectLiveWatch, checkLiveWatch, composeWatchFollowUp, WatchStore, watchContextBlock, contactWatchBlock } from "@lantern/bridge-core/live-watch"; import { SocialGraph, extractTopics, formatRelatedBlock } from "@lantern/bridge-core/social-graph"; import { assembleRelevantRecall } from "@lantern/bridge-core/recall"; import { classifyConfidence, tierBadge } from "@lantern/bridge-core/confidence-tier"; @@ -5606,6 +5606,23 @@ export class IMessageSession { this.scheduleDeliveryWatch(to, text); } } + // OUTBOUND AUDIT. A successful send was never logged with its text, so a + // whole week of traffic left only ~33 verbatim replies (from the two + // reply-meta sidecars) β€” reply QUALITY was structurally unmeasurable. + // Logged here, after delivery is confirmed, so the record reflects what + // the contact actually received. Bot-self acks/digests are tagged rather + // than dropped so "how much of what we send is real reply vs machinery" + // is answerable. Preview-capped like every other textPreview in this file + // (PII stays bounded; the full text is never logged). + this.logger.info( + { + to, + textPreview: text.slice(0, 160), + len: text.length, + botSelf: isBotSelfMessage(text), + }, + "outbound sent", + ); // Record so the polling loop skips this row when chat.db echoes // it back as is_from_me=1. this.recordBridgeSend(text); @@ -7438,8 +7455,15 @@ export class IMessageSession { ? assembleRelevantRecall(text, { episodes: allEpisodes, topics: related }) ?? undefined : undefined; + // Commitments this bot already made IN THIS THREAD (its own "I'll follow + // up" announcement is stripped from history as bot-self text). Scoped to + // this handle β€” another contact's watch never surfaces here. + const selfContextBlock = this.watchStore + ? contactWatchBlock(this.watchStore.all(), row.handle) + : ""; let systemHint = agentPersonaPrompt(ownerName, style, isGroup, { ownerSamples, + selfContextBlock, disclosed: false, stylePrompt, ownerProfile, @@ -10151,6 +10175,7 @@ export class IMessageSession { // PROACTIVE BRIEFING: "brief me" / "what's on my plate" β†’ assemble today's // calendar + who's waiting + open commitments (incl. promises) on demand. const briefingBlock = looksLikeBriefingRequest(query) ? await this.buildBriefingBlock() : ""; + const watchBlock = this.watchStore ? watchContextBlock(this.watchStore.all()) : ""; // Intelligent person-context: ground EVERY owner query on a real model of // the owner's people (profile + actual activity), so name resolution is // reasoned, not string-matched. @@ -10243,6 +10268,10 @@ export class IMessageSession { " β€’ Only after you've actually tried multiple angles do you say 'no clean hit on X β€” here's the closest I found' and stop. Never end with a 'want me to search more?' question.", " β€’ The ONE exception: a state-modifying follow-up ('saved to calendar?', 'want a reminder?') β€” that's allowed because it changes state.", "For ROSTER questions ('who came on X', 'who's in X'): the group rosters above are the truth. If members show as raw phone numbers (no name), their PARTICIPATION in the group already proves they were part of the trip/event. Answer with the FULL roster from the group; mention the unresolved ones as '+ N more (numbers only β€” names not in contacts yet)'. Do NOT ask the user if they want you to search further; if you can call search_whatsapp_history / search_imessage_history for the trip date range, JUST DO IT in this same turn.", + // Live watches are the bot's OWN commitments; its "πŸ“‘ watching:" + // announcement is filtered out of history by isBotSelfMessage, so + // without this the assistant denies work it is actively doing. + watchBlock ? "\n" + watchBlock : "", apptBlock ? "\n" + apptBlock : "", rosterBlock ? "\n" + rosterBlock : "", threadPeekBlock ? "\n" + threadPeekBlock : "", diff --git a/services/model-router/Cargo.lock b/services/model-router/Cargo.lock index c7bc764a..0847db20 100644 --- a/services/model-router/Cargo.lock +++ b/services/model-router/Cargo.lock @@ -275,7 +275,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -456,9 +456,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "h2" -version = "0.4.13" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -635,7 +635,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -1391,7 +1391,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1686,7 +1686,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/services/runtime-manager/Cargo.lock b/services/runtime-manager/Cargo.lock index b2374590..55581b53 100644 --- a/services/runtime-manager/Cargo.lock +++ b/services/runtime-manager/Cargo.lock @@ -920,7 +920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1199,9 +1199,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "h2" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -1473,7 +1473,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -2676,7 +2676,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -2713,7 +2713,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", "windows-sys 0.52.0", ] @@ -3025,7 +3025,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3565,7 +3565,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4725,7 +4725,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/services/whatsapp-bridge/src/session.ts b/services/whatsapp-bridge/src/session.ts index 34f44f07..eb43d616 100644 --- a/services/whatsapp-bridge/src/session.ts +++ b/services/whatsapp-bridge/src/session.ts @@ -26,7 +26,7 @@ import { addReminder as addRecurringReminder } from "@lantern/bridge-core/recurr import { addToList, removeFromList, loadList, renderList } from "@lantern/bridge-core/shared-list"; import { readWatchHistory, watchSummary, iphoneUsageBlock, isWatchQuery } from "@lantern/bridge-core/browser-history"; import { computeCommuteSurface, computeEnergyNudge, computeHealthCoachNudge, computeWeeklyHealthSummary, computeFocusGuardian } from "@lantern/bridge-core/proactive-loops"; -import { detectLiveWatch, checkLiveWatch, composeWatchFollowUp, WatchStore } from "@lantern/bridge-core/live-watch"; +import { detectLiveWatch, checkLiveWatch, composeWatchFollowUp, WatchStore, watchContextBlock, contactWatchBlock } from "@lantern/bridge-core/live-watch"; import { extractAutoFacts } from "@lantern/bridge-core/fact-extractor"; import { CalendarLookup, needsCalendar } from "@lantern/bridge-core/calendar"; import { @@ -3425,6 +3425,20 @@ export class WhatsAppSession { const sendOpts = opts.quoted ? { quoted: opts.quoted } : undefined; const sent = await this.socket.sendMessage(jid, { text }, sendOpts); if (sent?.key?.id) this.bridgeSentIds.set(sent.key.id, Date.now()); + // OUTBOUND AUDIT (twin of the iMessage bridge). A successful send was + // never logged with its text, so reply QUALITY was structurally + // unmeasurable β€” a whole week of traffic left only ~33 verbatim samples. + // Logged after the socket accepts it, tagging bot-self machinery rather + // than dropping it. Preview-capped like every other textPreview here. + this.logger.info( + { + to: jid, + textPreview: text.slice(0, 160), + len: text.length, + botSelf: isBotSelfMessage(text), + }, + "outbound sent", + ); // Return the sent message id so callers can retry-track the reply // (πŸ‘Ž / πŸ” on a contact-facing reply needs the GUID β†’ reply-meta). return sent?.key?.id ?? undefined; @@ -6319,6 +6333,7 @@ export class WhatsAppSession { // PROACTIVE BRIEFING: "brief me" / "what's on my plate" β†’ assemble today's // calendar + who's waiting + open commitments (incl. promises) on demand. const briefingBlock = looksLikeBriefingRequest(query) ? await this.buildBriefingBlock() : ""; + const watchBlock = this.watchStore ? watchContextBlock(this.watchStore.all()) : ""; // Intelligent person-context grounding (parity with iMessage). const knownPeopleBlock = this.ownerKnownPeopleBlock(); const systemHint = [ @@ -6396,6 +6411,10 @@ export class WhatsAppSession { `STATUS: when ${ownerName} tells you where they are or that they're away/busy/back (ANY phrasing or language β€” "I am at the pool till 7:30pm est", "in a meeting for 2h", "driving", "I'm back", Telugu, etc.), emit a [STATUS:...] marker. Compute the until-ISO from the time they gave (their local timezone; resolve "till 7:30pm" to today's datetime). When they say they're back/free/available, emit [STATUS:CLEAR]. The bridge then tells anyone who messages β€” on EVERY channel β€” that ${ownerName} is at and will get back, and offers to take a message. Confirm to ${ownerName} in your reply ("πŸ“ got it β€” you're at the pool till 7:30; I'll let people know.").`, `OFFER-then-CONFIRM applies ONLY to state-modifying actions (calendar, note, mail). For READ operations (search, list, look up, find), NEVER ask permission β€” just execute and report results. The user already asked; asking "shall I search?" is wasted turns.`, `For ROSTER questions ("who came on X", "who's in X"): the group rosters above are the truth. If a member appears as "(name unknown β€” group privacy)", that's WhatsApp's new privacy-preserving identifier (@lid) β€” we genuinely don't have their name because they haven't DM'd us. State the FULL roster size from the group AND list every name we DO have; for the rest say "N others (WhatsApp doesn't expose names of non-contacts in groups, unless they DM you)". Their PARTICIPATION in the group still proves they were on the trip. Do NOT ask the user if they want you to search further; if you can search WhatsApp/iMessage history for the trip date range, JUST DO IT in this same turn.`, + // Live watches are the bot's OWN commitments; its "πŸ“‘ watching:" + // announcement is filtered out of history by isBotSelfMessage, so + // without this the assistant denies work it is actively doing. + watchBlock ? "\n" + watchBlock : "", apptBlock ? "\n" + apptBlock : "", rosterBlock ? "\n" + rosterBlock : "", threadPeekBlock ? "\n" + threadPeekBlock : "", @@ -8694,6 +8713,12 @@ export class WhatsAppSession { !!opts.isGroup, { ownerSamples, + // Commitments this bot already made IN THIS THREAD; its own "I'll + // follow up" announcement is stripped from history as bot-self text. + // Scoped to this jid β€” another contact's watch never surfaces here. + selfContextBlock: this.watchStore + ? contactWatchBlock(this.watchStore.all(), from) + : "", disclosed: this.disclosedJids.has(from), stylePrompt, ownerProfile,