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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions packages/bridge-core/src/live-watch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
extractJson,
WatchStore,
type LiveWatch,
watchContextBlock,
contactWatchBlock,
} from "./live-watch.ts";

const NOW = 1_783_120_000_000;
Expand Down Expand Up @@ -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), "");
});
59 changes: 59 additions & 0 deletions packages/bridge-core/src/live-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <flight>" 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");
}
26 changes: 26 additions & 0 deletions packages/bridge-core/src/natural-audit-fixes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
});
37 changes: 36 additions & 1 deletion packages/bridge-core/src/natural.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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<string, RegExp>();
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
Expand Down Expand Up @@ -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}"` };
}

Expand Down
48 changes: 46 additions & 2 deletions packages/bridge-core/src/personal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,22 @@ export class PersonalClient {
opts: { windowDays?: number } = {},
): Promise<string> {
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 = "";
Expand All @@ -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;
});
Expand All @@ -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 ?? [])
Expand Down
Loading
Loading