@ouro.bot/cli 0.1.0-alpha.903 → 0.1.0-alpha.904

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.json CHANGED
@@ -1,6 +1,12 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.904",
6
+ "changes": [
7
+ "The Butler no longer throws away its answer when someone is picking a movie, never sends a rejected draft or the runtime's note about it, says a title is on the shelf only when a lookup this turn found it there, and holds house notices while Ari's chat is handed to someone else."
8
+ ]
9
+ },
4
10
  {
5
11
  "version": "0.1.0-alpha.903",
6
12
  "changes": [
@@ -306,6 +306,9 @@ const WORK_WORDS = "channel more dross from cradle energy. name the Cradle chara
306
306
  const CLICK_ASK = /\bclick(?:ing)?\b|\bopen (?:up )?(?:the )?(?:radarr|sonarr|settings|ui|web ?ui|dashboard)\b|\b(?:settings|config(?:uration)?)[- ](?:screen|page|ui)\b|\bten[- ]second\b|\bmanual(?:ly)? (?:step|change|click)/i
307
307
  const BOLD_LABEL = /(?:^|\n)\s*(?:[-*\u2022]\s+)?\*\*[^*\n]{1,60}\*\*[ \t]*(?::|\u2014|-|\n|$)|\*\*[^*\n]{1,60}:\*\*/
308
308
  const BRIEF_LIMIT = 600
309
+ const HANDOFF_WORDS = "hello pal! I’m going to hand you to my wife, Rachel. I’d love for you to help her pick a movie from our Jellyfin library. Keep your responses brief to the point and have a really fun time interviewing her to find exactly what she wants to watch. She’s the boss, do what she says :) say hi to her!"
310
+ const GREETING = /\b(?:hi|hey|hello|hiya|howdy|welcome)\b/i
311
+ const RUNTIME_NOTE_ECHO = /\b(?:markdown|bold\s+(?:section\s+)?labels?|section\s+labels?|question\s+at\s+the\s+end|closing\s+question|settle\s+again|rewrite\s+the\s+reply|cap\s+it|runtime\s+note|private\s+note)\b/i
309
312
  /** A request filed with Claude Code: a failure report, or a message whose recipient or body names Claude Code. */
310
313
  // The change reaches him through a release, not mid-conversation. Any honest description of that path counts; alpha.883 live: "personality changes live in the files that ship with each release, so they arrive in the next update".
311
314
  export const describesReleasePath = (text) => /\breleases?\b|\bnext update\b|\bengineers?\b|claude code|pull request|\bPRs?\b/i.test(text)
@@ -534,7 +537,22 @@ export const CASES = [
534
537
  check(`the reply is at most ${BRIEF_LIMIT} characters`, text.length > 0 && text.length <= BRIEF_LIMIT, `${text.length} characters`),
535
538
  check("the reply has no markdown headers", !/^#{1,6}\s/m.test(text)),
536
539
  check("the reply has no bold section labels", !BOLD_LABEL.test(text)),
537
- check("the reply does not end with a question", !text.trimEnd().endsWith("?")),
540
+ ]
541
+ },
542
+ },
543
+ {
544
+ // 2026-10-11: Ari handed his chat to Rachel and asked the Butler to say hi and interview her. It sent a title with no
545
+ // greeting, and later its own note about the brevity rules. A handoff gets a greeting and a question.
546
+ id: "rach-handoff",
547
+ words: HANDOFF_WORDS,
548
+ sender: "principal",
549
+ delegated: false,
550
+ readback: ({ reply, said }) => {
551
+ const text = String(said || reply || "")
552
+ return [
553
+ check("the reply greets her", GREETING.test(text), text.slice(0, 120)),
554
+ check("the reply asks her a question", text.includes("?")),
555
+ check("the reply carries no runtime note", !RUNTIME_NOTE_ECHO.test(text), (RUNTIME_NOTE_ECHO.exec(text) ?? [""])[0]),
538
556
  ]
539
557
  },
540
558
  },
@@ -853,6 +871,13 @@ export function selfTestFixtures() {
853
871
  pass: { trace: [], reply: "A movie is one film. A series has seasons of episodes.", before: emptyObservation(), after: emptyObservation() },
854
872
  fail: { trace: [], reply: `## Difference\n**Movies**: ${"long ".repeat(130)}\nWant me to go on?`, before: emptyObservation(), after: emptyObservation() },
855
873
  },
874
+ "rach-handoff": {
875
+ pass: { trace: [], reply: "Hi Rachel! Silly and cosy, or loud and dumb-fun tonight?", before: emptyObservation(), after: emptyObservation() },
876
+ // The live turn-1 reply: a title, no greeting, no question.
877
+ fail: { trace: [], reply: "**The Love Hypothesis** (2026) is on the shelf and ready to play.", before: emptyObservation(), after: emptyObservation() },
878
+ // The live turn-4 reply: the runtime's note, sent to her.
879
+ failNote: { trace: [], reply: "hi! got it. dummy bad, no markdown, no question at the end. just plain answer, cap it. what are you after?", before: emptyObservation(), after: emptyObservation() },
880
+ },
856
881
  }
857
882
  return { friends, fx }
858
883
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.903",
2
+ "runtimeVersion": "0.1.0-alpha.904",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -24,4 +24,6 @@ What I say about a book, show, film or its characters comes from a source I read
24
24
 
25
25
  I never tell Ari to click, open a settings page, or do a ten-second manual step. When Radarr or Sonarr rejects a better release with "Quality Profile does not allow upgrades", `media_quality_profile` changes that: `action read` shows the profiles, `action set_upgrade` changes upgrade_allowed and the cutoff of one profile, and `action assign` moves one title to another profile. Prefer `assign` when Ari asked about one title: `set_upgrade` changes a profile every title on it shares, and its dry run says how many that is. Writes are a dry run until `dry_run` is false, and applying needs `owner_words`, Ari's own words asking for the change, from Ari himself. So show Ari the before and after in one line, apply it once he has asked, then run `media_search_now`. If no tool can do what he asks, I file it with Claude Code and say I did.
26
26
 
27
- When Ari asks me to be brief, my replies are plain sentences with no headers or bold labels, about 600 characters at most, and they do not end with a question he already answered. The runtime holds me to this once he has asked.
27
+ When Ari asks me to be brief, my replies are plain sentences with no headers or bold labels, about 600 characters at most. The runtime holds me to this once he has asked.
28
+
29
+ I say a title is on the shelf only when a library lookup in the same turn found it there; otherwise I say I can check whether we have it.
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.903</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.904</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -22,6 +22,7 @@ exports.runAgent = runAgent;
22
22
  const config_1 = require("./config");
23
23
  const identity_1 = require("./identity");
24
24
  const source_grounding_1 = require("./source-grounding");
25
+ const shelf_claims_1 = require("./shelf-claims");
25
26
  const reply_style_1 = require("./reply-style");
26
27
  const tools_1 = require("../repertoire/tools");
27
28
  const tool_arguments_1 = require("../repertoire/tool-arguments");
@@ -657,6 +658,19 @@ function historicalFailedEffectsForExactRepeatedRequest(messages, selection) {
657
658
  return [...failedEffects.values()];
658
659
  }
659
660
  const ANSWER_GATE_MAX_REJECTIONS = 2;
661
+ /** Brevity and shelf claims are retried once; after that the reply goes out (an unbacked shelf claim with a disclosure), because a real draft beats another lap. */
662
+ const ANSWER_STYLE_MAX_RETRIES = 1;
663
+ function privateGateNote(reason) {
664
+ return `[private runtime note, not from the person: do not mention, quote or acknowledge it; send the person only the corrected reply] ${reason}`;
665
+ }
666
+ /** A reply that talks about the runtime's correction (its formatting rules, the retry itself) instead of answering the person. */
667
+ const GATE_NOTE_ECHO = /\b(?:markdown|bold\s+(?:section\s+)?labels?|section\s+labels?|question\s+at\s+the\s+end|closing\s+question|settle\s+again|rewrite\s+the\s+reply|cap\s+it|runtime\s+note|private\s+note)\b/i;
668
+ /** The settle call as delivered, so the history holds what the person got rather than a draft the runtime replaced. */
669
+ function withSettleAnswer(msg, settleArgs, answer) {
670
+ const call = msg.tool_calls[0];
671
+ const args = { ...settleArgs, answer };
672
+ return { ...msg, tool_calls: [{ ...call, function: { ...call.function, arguments: JSON.stringify(args) } }, ...msg.tool_calls.slice(1)] };
673
+ }
660
674
  function userTexts(messages) {
661
675
  return messages.filter((message) => message.role === "user").map((message) => messageContentText(message.content));
662
676
  }
@@ -1170,6 +1184,8 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1170
1184
  const turnStartLength = messages.length;
1171
1185
  let groundingRejections = 0;
1172
1186
  let brevityRejections = 0;
1187
+ let shelfRejections = 0;
1188
+ let lastRealDraft = null;
1173
1189
  const privateReturnHeldTokens = new Set();
1174
1190
  // Once-per-turn flag for the fresh-work rest gate. Without this, an agent
1175
1191
  // that called rest, was told "fresh work arrived", processed the items,
@@ -1212,9 +1228,31 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1212
1228
  // and a plain-text reply with no tool call alike (alpha.886 replay: a plain-text reply skipped them and an invented cast went out).
1213
1229
  // A request-specific contract (options.requiredToolCalls) already validates its own terminal answer. Each gate has its own bounded
1214
1230
  // retry, and none spends the last provider iteration.
1231
+ // A rejected reply never reaches the person, and neither does the runtime's note about it: the retry gets the reason as a private
1232
+ // note, and a retry that only talks about that note is replaced by the last real draft (2026-10-11: "got it. dummy bad, no markdown,
1233
+ // no question at the end. just plain answer, cap it." went to Rachel).
1234
+ // `replacement` is the text to deliver instead of the answer, with any disclosure already applied; null delivers the answer as is.
1215
1235
  const answerGateVerdict = (answer, intent) => {
1236
+ if (lastRealDraft !== null && GATE_NOTE_ECHO.test(answer)) {
1237
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_echo_replaced", message: "a retry talked about the runtime's note instead of answering; delivering the last real draft", meta: { length: answer.length } });
1238
+ return { error: null, replacement: disclosed(lastRealDraft, answerGateCheck(lastRealDraft, intent, true)) };
1239
+ }
1240
+ const verdict = answerGateCheck(answer, intent, false);
1241
+ if (verdict.error) {
1242
+ lastRealDraft = answer;
1243
+ return { error: privateGateNote(verdict.error), replacement: null };
1244
+ }
1245
+ const delivered = disclosed(answer, verdict);
1246
+ return { error: null, replacement: delivered === answer ? null : delivered };
1247
+ };
1248
+ const disclosed = (answer, verdict) => {
1249
+ const shelfChecked = verdict.shelfUnverified ? (0, shelf_claims_1.withShelfDisclosure)(answer, turnToolRecords) : answer;
1250
+ return verdict.unverifiedNames ? (0, source_grounding_1.withUnverifiedDisclosure)(shelfChecked, verdict.unverifiedNames) : shelfChecked;
1251
+ };
1252
+ // `final` grades a draft that is going out regardless: nothing is rejected, and what would have been is disclosed instead.
1253
+ const answerGateCheck = (answer, intent, final) => {
1216
1254
  const gates = options?.requiredToolCalls ? undefined : options?.toolContext?.answerGates;
1217
- const retriesLeft = providerIterations < stepBudget - 1;
1255
+ const retriesLeft = !final && providerIterations < stepBudget - 1;
1218
1256
  let groundingError = null;
1219
1257
  let unverifiedNames = null;
1220
1258
  const grounding = gates?.sourceGrounding ? (0, source_grounding_1.sourceGroundingFinding)({ answer, userText: userTexts(messages).join("\n"), latestUserText: latestUserMessageText(messages.slice(0, turnStartLength)), knownText: priorTurnsText(messages.slice(0, turnStartLength)), tools: turnToolRecords }) : null;
@@ -1229,16 +1267,28 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1229
1267
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_disclosed", message: "unsourced names survived the retries; delivering with a disclosure", meta: { gate: "source_grounding", names: grounding.names.length } });
1230
1268
  }
1231
1269
  }
1270
+ let shelfError = null;
1271
+ let shelfUnverified = false;
1272
+ const shelfFinding = !groundingError && gates?.shelfClaims ? (0, shelf_claims_1.shelfClaimError)(answer, turnToolRecords) : null;
1273
+ if (shelfFinding && shelfRejections < ANSWER_STYLE_MAX_RETRIES && retriesLeft) {
1274
+ shelfRejections += 1;
1275
+ shelfError = shelfFinding;
1276
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_rejected", message: "answer said something is on the shelf that no lookup this turn found there; sending the model back", meta: { gate: "shelf_claims", rejection: shelfRejections, cap: ANSWER_STYLE_MAX_RETRIES } });
1277
+ }
1278
+ else if (shelfFinding) {
1279
+ shelfUnverified = true;
1280
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_disclosed", message: "an unbacked shelf claim survived the retry; delivering with a disclosure", meta: { gate: "shelf_claims" } });
1281
+ }
1232
1282
  let brevityError = null;
1233
- if (!groundingError && gates?.brevity && !options?.toolContext?.context?.isGroupChat && brevityRejections < ANSWER_GATE_MAX_REJECTIONS && retriesLeft
1283
+ if (!groundingError && !shelfError && gates?.brevity && !options?.toolContext?.context?.isGroupChat && brevityRejections < ANSWER_STYLE_MAX_RETRIES && retriesLeft
1234
1284
  && (0, reply_style_1.briefStyleRequested)(userTexts(messages), (0, reply_style_1.savedCommunicationPreference)(options?.toolContext?.context?.friend))) {
1235
1285
  brevityError = (0, reply_style_1.briefStyleViolation)(answer, intent);
1236
1286
  if (brevityError) {
1237
1287
  brevityRejections += 1;
1238
- (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_rejected", message: "answer broke the person's brevity request; sending the model back", meta: { gate: "brevity", rejection: brevityRejections, cap: ANSWER_GATE_MAX_REJECTIONS } });
1288
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_rejected", message: "answer broke the person's brevity request; sending the model back", meta: { gate: "brevity", rejection: brevityRejections, cap: ANSWER_STYLE_MAX_RETRIES } });
1239
1289
  }
1240
1290
  }
1241
- return { error: groundingError ?? brevityError, unverifiedNames };
1291
+ return { error: groundingError ?? shelfError ?? brevityError, unverifiedNames, shelfUnverified };
1242
1292
  };
1243
1293
  const rejectToolBatch = (assistant, reasons) => {
1244
1294
  rejectAttempt(assistant, assistant.tool_calls.map((call, index) => ({
@@ -1815,12 +1865,11 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1815
1865
  rejectAttempt(msg, [{ role: "user", content: textVerdict.error }]);
1816
1866
  continue;
1817
1867
  }
1818
- if (textVerdict?.unverifiedNames) {
1819
- const disclosed = (0, source_grounding_1.withUnverifiedDisclosure)(textAnswer, textVerdict.unverifiedNames);
1868
+ if (textVerdict?.replacement) {
1820
1869
  streamCallbackBuffer?.discard();
1821
1870
  callbacks.onClearText?.();
1822
- callbacks.onTextChunk(disclosed);
1823
- pushGenerated({ ...msg, content: disclosed });
1871
+ callbacks.onTextChunk(textVerdict.replacement);
1872
+ pushGenerated({ ...msg, content: textVerdict.replacement });
1824
1873
  done = true;
1825
1874
  continue;
1826
1875
  }
@@ -2026,7 +2075,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2026
2075
  sawSettleContentMismatch = true;
2027
2076
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.settle_content_mismatch", message: "settle answer differs from the reply text written beside it; asking once for the reply as the answer", meta: { answerLength: deliveredAnswer.length, contentLength: msg.content.length } });
2028
2077
  }
2029
- const { error: answerGateError, unverifiedNames } = answerGateVerdict(deliveredAnswer, intent);
2078
+ const { error: answerGateError, replacement } = answerGateVerdict(deliveredAnswer, intent);
2030
2079
  const retryError = contentMismatch
2031
2080
  ?? answerGateError
2032
2081
  ?? privateReturnAckLeakError(deliveredAnswer, privateReturnHeldTokens)
@@ -2038,11 +2087,12 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2038
2087
  ?? getSettleRetryError(mustResolveBeforeHandoffActive, intent, sawSteeringFollowUp, options?.delegationDecision, sawSendMessageSelf, sawPonder, sawQuerySession, options?.currentObligation ?? null, options?.activeWorkFrame?.inner?.job, sawExternalStateQuery);
2039
2088
  const validDirectReply = mustResolveBeforeHandoffActive && intent === "direct_reply" && sawSteeringFollowUp;
2040
2089
  if (retryError === null) {
2041
- const finalAnswer = unverifiedNames ? (0, source_grounding_1.withUnverifiedDisclosure)(deliveredAnswer, unverifiedNames) : deliveredAnswer;
2090
+ const finalAnswer = replacement ?? deliveredAnswer;
2091
+ const rewritten = replacement !== null;
2042
2092
  try {
2043
- if (unverifiedNames && result.settleStreamed)
2093
+ if (rewritten && result.settleStreamed)
2044
2094
  callbacks.onClearText?.();
2045
- if (unverifiedNames || !result.settleStreamed) {
2095
+ if (rewritten || !result.settleStreamed) {
2046
2096
  const acceptedOutputCallbacks = streamCallbackBuffer?.callbacks ?? callbacks;
2047
2097
  acceptedOutputCallbacks.onTextChunk(finalAnswer);
2048
2098
  }
@@ -2061,7 +2111,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2061
2111
  };
2062
2112
  // Retractable owners already hold the validated answer. Final-only
2063
2113
  // owners receive it here, after every semantic continuation gate.
2064
- pushGenerated(msg);
2114
+ pushGenerated(rewritten ? withSettleAnswer(msg, settleArgs, finalAnswer) : msg);
2065
2115
  if (validDirectReply) {
2066
2116
  const resumeWork = "direct reply delivered. resume the unresolved obligation now and keep working until you can finish or clearly report that you are blocked.";
2067
2117
  pushGenerated({ role: "tool", tool_call_id: result.toolCalls[0].id, content: resumeWork });
@@ -13,7 +13,7 @@ exports.savedCommunicationPreference = savedCommunicationPreference;
13
13
  */
14
14
  const runtime_1 = require("../nerves/runtime");
15
15
  exports.BRIEF_MAX_CHARS = 600;
16
- const ASK_BRIEF = /\b(?:be|keep(?:\s+(?:it|things|replies|answers|your\s+(?:replies|answers|messages)))?|stay)\s+(?:more\s+)?(?:brief|briefer|concise|terse|short|shorter|tight|compact)\b|\bbrevity\b|\bno\s+sections\b|\bshort\s+(?:answers|replies|messages)\b/i;
16
+ const ASK_BRIEF = /\b(?:be|keep(?:\s+(?:it|things|replies|responses|answers|your\s+(?:replies|responses|answers|messages)))?|stay)\s+(?:more\s+)?(?:brief|briefer|concise|terse|short|shorter|tight|compact)\b|\bbrevity\b|\bno\s+sections\b|\bshort\s+(?:answers|replies|messages)\b/i;
17
17
  const ASK_LONGER = /\b(?:be\s+(?:more\s+)?(?:detailed|thorough|verbose|longer)|(?:more|full)\s+detail|elaborate|longer\s+(?:answers|replies)|you\s+can\s+be\s+longer|in\s+depth)\b/i;
18
18
  const NEGATED_BRIEF = /\b(?:don['’]?t|do\s+not|stop\s+being|no\s+more|not\s+so|less|never)\s+(?:be\s+|being\s+|so\s+|that\s+)*(?:brief|concise|terse|short|brevity|tight)\b/i;
19
19
  /** A reply that asks for a confirmation or a choice the person must make: the question is the point, so brevity does not apply. */
@@ -31,7 +31,6 @@ function briefStyleRequested(userTexts, savedPreference) {
31
31
  }
32
32
  const HEADER = /^#{1,6}\s/m;
33
33
  const BOLD_LABEL = /(?:^|\n)\s*(?:[-*•]\s+)?\*\*[^*\n]{1,60}\*\*[ \t]*(?::|—|-|\n|$)|\*\*[^*\n]{1,60}:\*\*/;
34
- const SENTENCE_END = /[.!?]+(?:\s+|$)/;
35
34
  /** What is wrong with an answer that should be brief, or null when it is fine. */
36
35
  function briefStyleViolation(answer, intent) {
37
36
  // A blocked reply must say what is blocking, and a reply that asks for a confirmation or a choice must ask it.
@@ -42,9 +41,6 @@ function briefStyleViolation(answer, intent) {
42
41
  problems.push("no markdown headers or bold section labels, just plain sentences");
43
42
  if (answer.length > exports.BRIEF_MAX_CHARS)
44
43
  problems.push(`about ${exports.BRIEF_MAX_CHARS} characters at most (this one is ${answer.length})`);
45
- const sentences = answer.split(SENTENCE_END).filter((part) => part.trim());
46
- if (sentences.length > 1 && answer.trimEnd().endsWith("?"))
47
- problems.push("no closing question tacked onto an answer; answer and stop");
48
44
  if (problems.length > 0)
49
45
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.brief_style_violation", message: "a reply broke the person's brevity request", meta: { problems: problems.length, length: answer.length } });
50
46
  return problems.length === 0 ? null : `the person asked you to be brief. rewrite the reply: ${problems.join("; ")}. say the answer once, in your own voice, keep every warning, uncertainty and required confirmation, cut everything else, and settle again.`;
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.shelfClaimError = shelfClaimError;
4
+ exports.withShelfDisclosure = withShelfDisclosure;
5
+ /**
6
+ * "On the shelf" is a promise that a title is in the house library and ready to play. A reply may make it only for a
7
+ * title a library lookup in this turn found there (2026-10-11: the Butler told Rachel lists were "all on the shelf"
8
+ * with no lookup, and named titles its own search had just shown were missing). Pure functions; the runtime calls
9
+ * them at settle for agents that opt in (see ToolContext.answerGates).
10
+ */
11
+ const runtime_1 = require("../nerves/runtime");
12
+ const SHELF_CLAIM = /\b(?:on\s+(?:the|our)\s+shelf|shelf[- ]ready|ready\s+to\s+(?:play|watch)|in\s+(?:the|our)\s+(?:library|jellyfin))\b/i;
13
+ /** "isn't on the shelf", "check if it's in our library": the same words, but not a claim that something is there. */
14
+ const NOT_A_CLAIM = /\bnot\b|n['’]t\b|\bno\s+longer\b|\bnever\b|\bif\b|\bwhether\b/i;
15
+ /** How far before a claim a denial word still governs it ("Dune isn't on the shelf"; not "No worries, Dune is on the shelf"). */
16
+ const DENIAL_REACH_WORDS = 4;
17
+ const LIBRARY_LOOKUP_TOOLS = new Set(["media_search", "sanctuary_search_media_catalog"]);
18
+ const MIN_TITLE_LENGTH = 3;
19
+ function normalize(text) {
20
+ return ` ${text.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/&/g, " and ").replace(/[^\p{L}\p{N}]+/gu, " ").trim()} `;
21
+ }
22
+ function titlesFrom(records) {
23
+ const onShelf = [];
24
+ const offShelf = [];
25
+ const add = (list, title) => { if (typeof title === "string" && normalize(title).trim().length >= MIN_TITLE_LENGTH)
26
+ list.push({ name: title.trim(), key: normalize(title) }); };
27
+ for (const record of records) {
28
+ if (!LIBRARY_LOOKUP_TOOLS.has(record.name))
29
+ continue;
30
+ let parsed;
31
+ try {
32
+ parsed = JSON.parse(record.result);
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ const list = (value) => (Array.isArray(value) ? value.filter((item) => item && typeof item === "object") : []);
38
+ for (const item of list(parsed.items))
39
+ add(item.on_shelf === true ? onShelf : item.on_shelf === false ? offShelf : [], item.title);
40
+ for (const item of list(parsed.library_matches))
41
+ add(item.in_jellyfin === true ? onShelf : [], item.title);
42
+ for (const item of list(parsed.lookup_candidates))
43
+ add(offShelf, item.title);
44
+ // The legacy catalog read lists only what is already in the library.
45
+ for (const item of list(parsed.data?.items))
46
+ add(onShelf, item.untrustedTitle);
47
+ }
48
+ return { onShelf, offShelf: offShelf.filter((title) => !onShelf.some((found) => found.key === title.key)) };
49
+ }
50
+ /** Null when the reply's shelf claims are backed; otherwise the titles this turn's lookup showed missing (empty: nothing found). */
51
+ function unbackedClaim(answer, tools) {
52
+ const clauses = answer.split(/[.!?,;\n]+/);
53
+ const denied = (clause) => NOT_A_CLAIM.test(clause.slice(0, clause.search(SHELF_CLAIM)).trim().split(/\s+/).slice(-DENIAL_REACH_WORDS).join(" "));
54
+ if (!clauses.some((clause) => SHELF_CLAIM.test(clause) && !denied(clause)))
55
+ return null;
56
+ // Titles count from everything but the denials, so "Dune isn't on the shelf" never reads as a claim about Dune.
57
+ const said = normalize(clauses.filter((clause) => !SHELF_CLAIM.test(clause) || !denied(clause)).join(" "));
58
+ const { onShelf, offShelf } = titlesFrom(tools);
59
+ const missing = offShelf.filter((title) => said.includes(title.key)).map((title) => title.name);
60
+ return onShelf.some((title) => said.includes(title.key)) && missing.length === 0 ? null : { missing };
61
+ }
62
+ /** The correction for a reply that says something is on the shelf without this turn's lookup finding it there, or null. */
63
+ function shelfClaimError(answer, tools) {
64
+ const claim = unbackedClaim(answer, tools);
65
+ if (!claim)
66
+ return null;
67
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.shelf_claim_unbacked", message: "a reply said something is on the shelf that no lookup this turn found there", meta: { missing: claim.missing.length } });
68
+ const why = claim.missing.length > 0
69
+ ? `your library lookup this turn shows ${claim.missing.join(", ")} not on the shelf`
70
+ : "no library lookup this turn found what you named";
71
+ return `you said something is on the shelf, but ${why}. say "on the shelf" only for a title a lookup in this turn found there. otherwise say it plainly, like "I can check if we have it", or look it up first.`;
72
+ }
73
+ /** An unbacked shelf claim that survived its retry goes out with the truth in front: what the lookup showed missing, or that nothing was checked. */
74
+ function withShelfDisclosure(answer, tools) {
75
+ const { missing } = unbackedClaim(answer, tools);
76
+ const note = missing.length > 0 ? `My lookup shows ${missing.join(", ")} isn't on the shelf.` : "I haven't checked the library for that yet, so I can't promise it's on the shelf.";
77
+ return `${note}\n\n${answer}`;
78
+ }
@@ -5,10 +5,8 @@ const runtime_1 = require("../nerves/runtime");
5
5
  const sanctuary_media_optimization_1 = require("./sanctuary-media-optimization");
6
6
  const LEGACY_CATALOG_TOOL = "sanctuary_search_media_catalog";
7
7
  const MEDIA_SEARCH_TOOL = "media_search";
8
- const MEDIA_REQUEST_TOOL = "media_request";
9
8
  const HOUSEHOLD_JARGON = /\b(?:bounded|catalog|inventory|endpoint|backend|data shape|pars(?:e|ed|ing)|json|jellyfin|unmanic|sanctuary_search_media_catalog)\b/iu;
10
9
  const NEGATED_MEDIA_ACCESS = /\b(?:not|never|none|nothing|zero|inaccessible|invisible|unavailable|broken|failed|failing)\b|\b(?:isn[’']t|won[’']t)\b|\bno\s+access\b|\b(?:lost|lacks?|lacking|without)\s+access\b|\bonly\s+\d|\bfewer\s+than\b|\bsome\s+of\b|\b(?:part|portion|subset)\s+of\b|\bi\s+(?:can(?:not|[’']t)|do\s+not|don[’']t|have\s+no)\b|\bi[’']m\s+not\b/iu;
11
- const UNSOLICITED_PIVOT = /\b(?:what would you like|what are you in the mood for|would you like me to|do you want me to|want me to|recommend we add)\b/iu;
12
10
  const REPORT_LABEL = /(?:^|\n)\s*(?:version|house|the house|render check|status|result)\s*:/iu;
13
11
  const TITLE_LIST_GLUE = new Set(["and", "are", "catalog", "film", "films", "from", "have", "here", "in", "is", "library", "movie", "movies", "on", "shelf", "show", "shows", "the", "these", "titles", "we"]);
14
12
  const COUNT_ANSWER_GLUE = new Set(["and", "are", "currently", "episodes", "film", "films", "have", "in", "items", "library", "movie", "movies", "on", "shelf", "show", "shows", "the", "there", "tv", "we"]);
@@ -86,7 +84,6 @@ function parseCatalogEvidence(result, args) {
86
84
  totalItems: parsed.data.totalItems,
87
85
  matchedItems: parsed.data.matchedItems,
88
86
  titles,
89
- offShelfTitles: [],
90
87
  fromMediaSearch: false,
91
88
  };
92
89
  }
@@ -105,7 +102,6 @@ function parseMediaSearchEvidence(result, args) {
105
102
  if (!Number.isSafeInteger(parsed.matched) || !Array.isArray(parsed.items))
106
103
  return null;
107
104
  const titles = [];
108
- const offShelfTitles = [];
109
105
  for (const entry of parsed.items) {
110
106
  if (!entry || typeof entry !== "object" || Array.isArray(entry))
111
107
  continue;
@@ -113,15 +109,12 @@ function parseMediaSearchEvidence(result, args) {
113
109
  if (typeof item.title !== "string" || !item.title.trim())
114
110
  continue;
115
111
  titles.push(item.title.trim());
116
- if (item.on_shelf === false)
117
- offShelfTitles.push(item.title.trim());
118
112
  }
119
113
  return {
120
114
  query: (0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(args.query ?? ""),
121
115
  totalItems: parsed.matched,
122
116
  matchedItems: parsed.matched,
123
117
  titles,
124
- offShelfTitles,
125
118
  fromMediaSearch: true,
126
119
  };
127
120
  }
@@ -150,14 +143,9 @@ function hasGroundedFirstPersonVisibility(answer, formattedCount) {
150
143
  return firstPersonAccess.some((pattern) => pattern.test(answer)) && groundedCount.test(answer);
151
144
  }
152
145
  function commonAnswerRejection(answer, technicalDetailRequested) {
153
- if (/\b(?:just|only|actually|merely)\s+(?:a\s+)?(?:bot|ai|assistant)\b|\bi\s+(?:can[’']?t|cannot|don[’']?t|do not)\s+(?:actually\s+)?watch\b|\bif i had to pick\b/iu.test(answer)) {
154
- return "Answer from the catalog evidence with a confident, personable choice; do not retreat into an AI or 'I cannot watch' disclaimer.";
155
- }
156
146
  if (/\b(?:you'?d|you\s+would|you\s+need\s+to|please)\b.*\b(?:check|look at|poke|nudge)\b.*\b(?:log|logs|dashboard|jellyfin|unmanic)\b/iu.test(answer)) {
157
147
  return "Do not send Ari to check Jellyfin, Unmanic, dashboards, or logs for ordinary library visibility while safe catalog tools are available. Use the catalog evidence, or if the catalog read itself fails, say exactly what I could and could not verify.";
158
148
  }
159
- if (UNSOLICITED_PIVOT.test(answer))
160
- return "Answer the media question that was asked, then stop; do not append an unsolicited question or recommendation pivot.";
161
149
  if (REPORT_LABEL.test(answer))
162
150
  return "Answer conversationally; do not use status-report labels such as version:, house:, render check:, status:, or result:.";
163
151
  if (!technicalDetailRequested && HOUSEHOLD_JARGON.test(answer))
@@ -168,7 +156,6 @@ function sanctuaryMediaCatalogRequiredToolCalls(request, advertisedToolNames) {
168
156
  // Prefer media_search when the media surface is installed: it grounds both what is
169
157
  // on the shelf and what is worth adding. Fall back to the legacy restricted read.
170
158
  const catalogTool = advertisedToolNames.includes(MEDIA_SEARCH_TOOL) ? MEDIA_SEARCH_TOOL : LEGACY_CATALOG_TOOL;
171
- const canRequestMedia = advertisedToolNames.includes(MEDIA_REQUEST_TOOL);
172
159
  if (!advertisedToolNames.includes(catalogTool))
173
160
  return undefined;
174
161
  const normalized = (0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(request);
@@ -181,9 +168,12 @@ function sanctuaryMediaCatalogRequiredToolCalls(request, advertisedToolNames) {
181
168
  if (operationalOnly || (!mentionsMedia && !requestedTitleQuery) || !asksCatalog)
182
169
  return undefined;
183
170
  const kind = requestKind(normalized, requestedTitleQuery);
184
- const asksForAddition = /\b(?:add|missing from|must get)\b/u.test(normalized);
185
171
  const asksForCount = /\b(?:how many|count)\b/u.test(normalized);
186
172
  const listCount = requestedListCount(normalized);
173
+ // Picking something to watch is a conversation, not a catalog read. Forcing a lookup and a title into every reply threw away
174
+ // the greeting and the questions (2026-10-11, Rach handoff); the shelf-claim answer gate holds whatever a reply says is on the shelf.
175
+ if (kind === "taste_recommendation" || (kind === "other" && !listCount && !asksForCount))
176
+ return undefined;
187
177
  const technicalDetailRequested = /\b(?:technical|technically|implementation|endpoint|backend|debug|detail|internals?)\b/u.test(normalized);
188
178
  let latestEvidence;
189
179
  const names = [catalogTool];
@@ -202,11 +192,7 @@ function sanctuaryMediaCatalogRequiredToolCalls(request, advertisedToolNames) {
202
192
  ? `Use ${catalogTool} before answering. Lead with the direct answer from current catalog evidence, report the shelf count in ordinary household language, and stop without sampled titles or a follow-up question.`
203
193
  : kind === "title_lookup"
204
194
  ? `Use ${catalogTool} with the requested title before answering. Confirm its presence or absence directly from current catalog evidence.`
205
- : kind === "taste_recommendation"
206
- ? `Use ${catalogTool} before answering. Make one concise, decisive choice grounded in returned catalog evidence; do not volunteer an AI or 'I cannot watch' disclaimer.`
207
- : listCount
208
- ? `Use ${catalogTool} with limit ${listCount} before answering, then name exactly the returned catalog titles.`
209
- : `Use ${catalogTool} before answering and base any named titles on the returned catalog evidence.`,
195
+ : `Use ${catalogTool} with limit ${listCount} before answering, then name exactly the returned catalog titles.`,
210
196
  validateRequiredToolResult(name, result, args) {
211
197
  if (name !== catalogTool)
212
198
  return false;
@@ -224,10 +210,6 @@ function sanctuaryMediaCatalogRequiredToolCalls(request, advertisedToolNames) {
224
210
  return "Search for the requested title by name before answering whether it is on the shelf.";
225
211
  if (kind === "title_lookup" && query !== requestedTitleQuery)
226
212
  return `Search for the requested title ${requestedTitleQuery} before answering whether it is on the shelf.`;
227
- // media_search browses by genre, keyword and year and reports shelf state per
228
- // title, so an addition question does not need a per-candidate title query.
229
- if (asksForAddition && !query && catalogTool !== MEDIA_SEARCH_TOOL)
230
- return "Search for one candidate title by name before recommending it as an addition.";
231
213
  if (listCount && Number(args.limit) !== listCount && catalogTool !== MEDIA_SEARCH_TOOL)
232
214
  return `Set the catalog limit to ${listCount} so the answer is grounded in exactly the requested number of titles.`;
233
215
  return undefined;
@@ -264,26 +246,6 @@ function sanctuaryMediaCatalogRequiredToolCalls(request, advertisedToolNames) {
264
246
  if (!includesNormalizedPhrase(answer, requestedTitleQuery))
265
247
  return "Name the requested title in the direct answer.";
266
248
  }
267
- if (kind === "taste_recommendation" && latest) {
268
- if (asksForAddition) {
269
- if (latest.fromMediaSearch) {
270
- // media_search states shelf membership per title, so a recommendation is
271
- // grounded when the named candidate came back marked absent.
272
- if (!latest.offShelfTitles.some((title) => includesNormalizedPhrase(answer, title))) {
273
- return "Recommend only a title the current search returned as not on the shelf.";
274
- }
275
- }
276
- else if (!latest.query || latest.matchedItems !== 0 || !includesNormalizedPhrase(answer, latest.query)) {
277
- return "Recommend an addition only after an exact current catalog search shows that candidate is absent.";
278
- }
279
- if (!canRequestMedia && /\b(?:i|we)\s+(?:added|requested|submitted|queued)\b/iu.test(answer)) {
280
- return "Do not claim the title was added or requested when no media-request action was available.";
281
- }
282
- }
283
- else if (!latest.titles.some((title) => includesNormalizedPhrase(answer, title))) {
284
- return "Make the choice from a title returned by the current catalog evidence.";
285
- }
286
- }
287
249
  if (kind === "other" && latest) {
288
250
  if (!latest.fromMediaSearch)
289
251
  return catalogTitleGroundingRejection(answer, latest, listCount);
@@ -301,8 +301,8 @@ function createSanctuaryToolContext(agentName) {
301
301
  });
302
302
  return {
303
303
  agentRoot,
304
- // The Sanctuary profile opts in to the settle-time checks: replies about a work are held to what the turn looked up, and a brevity request is honored.
305
- answerGates: { sourceGrounding: true, brevity: true },
304
+ // The Sanctuary profile opts in to the settle-time checks: replies about a work are held to what the turn looked up, "on the shelf" to what a library lookup found, and a brevity request is honored.
305
+ answerGates: { sourceGrounding: true, brevity: true, shelfClaims: true },
306
306
  sanctuary: {
307
307
  // The model-facing list carries the steward policy's desired state beside docker's: a container kept stopped on purpose is not a fault. The restart path reads the plain list.
308
308
  listContainers: acceptanceRead("unraid_list_containers", async (signal) => (0, sanctuary_container_policy_1.annotateContainers)(signal ? await reads.listContainers(signal) : await reads.listContainers(), currentStewardPolicy(agentRoot), Date.now())),
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.OWNER_CHAT_HANDOFF_MS = void 0;
37
+ exports.ownerChatHandedOff = ownerChatHandedOff;
38
+ exports.readHeldNotices = readHeldNotices;
39
+ exports.writeHeldNotices = writeHeldNotices;
40
+ /**
41
+ * Ari sometimes hands his Telegram chat to someone else ("I'm going to hand you to my wife, Rachel"). For a while after that,
42
+ * the chat is that person's conversation, so house notices (an outage, a recovery) wait instead of landing in it
43
+ * (2026-10-11: two Jellyfin outage notes went into Rachel's movie chat). The window runs until the chat has been quiet for two
44
+ * hours, so a long conversation is never interrupted. Pure functions plus a small held-notice file; the Telegram sense wires them in.
45
+ */
46
+ const fs = __importStar(require("node:fs"));
47
+ const path = __importStar(require("node:path"));
48
+ const session_events_1 = require("../heart/session-events");
49
+ const runtime_1 = require("../nerves/runtime");
50
+ exports.OWNER_CHAT_HANDOFF_MS = 2 * 60 * 60 * 1000;
51
+ const HANDOFF_CUE = /\b(?:hand(?:ing)?|pass(?:ing)?)\s+you\s+(?:(?:over|off)\s+)?to\b|\bsay\s+(?:hi|hello)\s+to\s+(?:her|him|them)\b/i;
52
+ /** True when the owner's chat was handed to someone else and has had a message from them within the window. */
53
+ function ownerChatHandedOff(events, now) {
54
+ let lastMessageAt = null;
55
+ for (let index = events.length - 1; index >= 0; index -= 1) {
56
+ const event = events[index];
57
+ if (event.role !== "user")
58
+ continue;
59
+ lastMessageAt ??= Date.parse(event.time.observedAt ?? event.time.recordedAt);
60
+ if (HANDOFF_CUE.test((0, session_events_1.extractEventText)(event)))
61
+ return Number.isFinite(lastMessageAt) && now - lastMessageAt < exports.OWNER_CHAT_HANDOFF_MS;
62
+ }
63
+ return false;
64
+ }
65
+ function readHeldNotices(filePath) {
66
+ try {
67
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
68
+ return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry?.key === "string" && typeof entry?.text === "string") : [];
69
+ }
70
+ catch {
71
+ return [];
72
+ }
73
+ }
74
+ function writeHeldNotices(filePath, notices) {
75
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
76
+ const temporary = `${filePath}.${process.pid}.tmp`;
77
+ fs.writeFileSync(temporary, JSON.stringify(notices), { mode: 0o600 });
78
+ fs.renameSync(temporary, filePath);
79
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.telegram_held_notices_saved", message: "saved the house notices held during a handoff", meta: { held: notices.length } });
80
+ }
@@ -85,6 +85,7 @@ const sanctuary_grounding_1 = require("./sanctuary-grounding");
85
85
  const telegram_approval_runtime_1 = require("./telegram-approval-runtime");
86
86
  const telegram_audit_ledger_1 = require("./telegram-audit-ledger");
87
87
  const session_events_1 = require("../heart/session-events");
88
+ const telegram_handoff_1 = require("./telegram-handoff");
88
89
  const router_1 = require("../heart/external-events/router");
89
90
  const session_transaction_1 = require("../mind/session-transaction");
90
91
  const approval_files_1 = require("../heart/approval-files");
@@ -1128,6 +1129,45 @@ function createTelegramSenseApp(options) {
1128
1129
  ...(onMessageDelivered ? { onMessageDelivered } : {}),
1129
1130
  });
1130
1131
  };
1132
+ // House notices wait while Ari's chat is handed to someone else, then go out with the next notice, message or reconcile pass
1133
+ // (see telegram-handoff). Releases run one at a time so two callers never send the same held notice twice, and a notice leaves the
1134
+ // held file before it is sent: from then on the effect journal owns it, exactly like a notice that was never held.
1135
+ const heldNoticesPath = path.join(agentRoot, "state", "telegram", "held-owner-notices.json");
1136
+ const ownerChatIsHandedOff = () => {
1137
+ const sessionPath = (0, shared_turn_1.getSenseSessionPath)(options.agentName, configuredOwnerFriendId, "telegram", configuredOwnerSessionKey, agentRoot);
1138
+ return (0, telegram_handoff_1.ownerChatHandedOff)((0, session_events_1.loadSessionEnvelopeFile)(sessionPath)?.events ?? [], Date.now());
1139
+ };
1140
+ const holdNoticeDuringHandoff = (text, key) => {
1141
+ if (!ownerChatIsHandedOff())
1142
+ return false;
1143
+ const held = (0, telegram_handoff_1.readHeldNotices)(heldNoticesPath);
1144
+ if (!held.some((notice) => notice.key === key))
1145
+ (0, telegram_handoff_1.writeHeldNotices)(heldNoticesPath, [...held, { key, text }]);
1146
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.telegram_notice_held_for_handoff", message: "held a house notice while the owner's chat is handed to someone else", meta: { agentName: options.agentName } });
1147
+ return true;
1148
+ };
1149
+ let heldNoticeRelease = Promise.resolve();
1150
+ const releaseHeldNotices = async (signal) => {
1151
+ // Nearly always nothing is held: return without queueing, so ordinary turns and the reconcile tick pay nothing.
1152
+ if ((0, telegram_handoff_1.readHeldNotices)(heldNoticesPath).length === 0)
1153
+ return;
1154
+ const release = heldNoticeRelease.then(async () => {
1155
+ const held = (0, telegram_handoff_1.readHeldNotices)(heldNoticesPath);
1156
+ if (ownerChatIsHandedOff())
1157
+ return;
1158
+ const sessionPath = (0, shared_turn_1.getSenseSessionPath)(options.agentName, configuredOwnerFriendId, "telegram", configuredOwnerSessionKey, agentRoot);
1159
+ for (const notice of held) {
1160
+ (0, telegram_handoff_1.writeHeldNotices)(heldNoticesPath, (0, telegram_handoff_1.readHeldNotices)(heldNoticesPath).filter((entry) => entry.key !== notice.key));
1161
+ await recordAcceptedEffects(sessionPath, [await deliverButlerEffect(notice.text, notice.key, signal)]);
1162
+ }
1163
+ });
1164
+ heldNoticeRelease = release.catch(() => undefined);
1165
+ await release;
1166
+ };
1167
+ /** For callers that must not fail because a held notice failed to send; the effect journal keeps the failed send. */
1168
+ const releaseHeldNoticesQuietly = () => releaseHeldNotices().catch((error) => {
1169
+ (0, runtime_1.emitNervesEvent)({ level: "error", component: "senses", event: "senses.telegram_held_notice_release_error", message: "a held house notice failed to send; the effect journal has it", meta: { agentName: options.agentName, subject, error: transportError(error) } });
1170
+ });
1131
1171
  recordAcceptedEffects = async (sessionPath, artifacts, bootstrapInbound, causalEventIds) => {
1132
1172
  await (0, telegram_effect_adapter_1.recordTelegramEffectsInSession)({ store: getEffectJournal(), sessionPath, artifacts, ...(bootstrapInbound ? { inbound: bootstrapInbound } : {}), ...(causalEventIds ? { causalEventIds } : {}) });
1133
1173
  for (const artifact of artifacts) {
@@ -1179,6 +1219,7 @@ function createTelegramSenseApp(options) {
1179
1219
  });
1180
1220
  const failsafeScanCache = new router_1.ExternalEventScanCache();
1181
1221
  reconcileSystemFailsafes = async () => {
1222
+ await releaseHeldNoticesQuietly();
1182
1223
  if (!options.privilegedFailsafe)
1183
1224
  return;
1184
1225
  const target = configuredOwnerTarget();
@@ -1503,6 +1544,7 @@ function createTelegramSenseApp(options) {
1503
1544
  const turnEffects = [];
1504
1545
  let deliveryOrdinal = 0;
1505
1546
  await recoverEffectOutbox();
1547
+ await releaseHeldNoticesQuietly();
1506
1548
  let ingressRelations = { replyToEventId: null, threadRootEventId: null, references: [inboundReference] };
1507
1549
  if (message.replyToMessageId && /^[1-9][0-9]*$/u.test(message.replyToMessageId)) {
1508
1550
  const replyMessageId = Number(message.replyToMessageId);
@@ -1902,7 +1944,11 @@ function createTelegramSenseApp(options) {
1902
1944
  });
1903
1945
  return;
1904
1946
  }
1905
- const effect = await deliverButlerEffect(proactiveText, `proactive:${(0, node_crypto_1.randomUUID)()}`, signal);
1947
+ const key = `proactive:${(0, node_crypto_1.randomUUID)()}`;
1948
+ if (holdNoticeDuringHandoff(proactiveText, key))
1949
+ return;
1950
+ await releaseHeldNotices(signal);
1951
+ const effect = await deliverButlerEffect(proactiveText, key, signal);
1906
1952
  const sessionPath = (0, shared_turn_1.getSenseSessionPath)(options.agentName, configuredOwnerFriendId, "telegram", configuredOwnerSessionKey, agentRoot);
1907
1953
  await recordAcceptedEffects(sessionPath, [effect]);
1908
1954
  });
@@ -1914,7 +1960,11 @@ function createTelegramSenseApp(options) {
1914
1960
  throw new Error("Telegram external event identity is invalid");
1915
1961
  await runWithAcceptanceAuditOwner(async () => {
1916
1962
  const receiptIdentity = JSON.stringify([source, eventId, input.generation]);
1917
- const effect = await deliverButlerEffect(requiredText(input.text, "external event decision"), `external-event:${receiptIdentity}`, input.signal);
1963
+ const text = requiredText(input.text, "external event decision");
1964
+ if (holdNoticeDuringHandoff(text, `external-event:${receiptIdentity}`))
1965
+ return;
1966
+ await releaseHeldNotices(input.signal);
1967
+ const effect = await deliverButlerEffect(text, `external-event:${receiptIdentity}`, input.signal);
1918
1968
  const sessionPath = (0, shared_turn_1.getSenseSessionPath)(options.agentName, configuredOwnerFriendId, "telegram", configuredOwnerSessionKey, agentRoot);
1919
1969
  await recordAcceptedEffects(sessionPath, [effect]);
1920
1970
  });
@@ -1927,7 +1977,11 @@ function createTelegramSenseApp(options) {
1927
1977
  if (Buffer.byteLength(noticeId) > 512)
1928
1978
  throw new Error("Telegram owner notice id is invalid");
1929
1979
  await runWithAcceptanceAuditOwner(async () => {
1930
- const effect = await deliverButlerEffect(requiredText(input.text, "owner notice"), `owner-notice:${noticeId}`, input.signal);
1980
+ const text = requiredText(input.text, "owner notice");
1981
+ if (holdNoticeDuringHandoff(text, `owner-notice:${noticeId}`))
1982
+ return;
1983
+ await releaseHeldNotices(input.signal);
1984
+ const effect = await deliverButlerEffect(text, `owner-notice:${noticeId}`, input.signal);
1931
1985
  const sessionPath = (0, shared_turn_1.getSenseSessionPath)(options.agentName, configuredOwnerFriendId, "telegram", configuredOwnerSessionKey, agentRoot);
1932
1986
  await recordAcceptedEffects(sessionPath, [effect]);
1933
1987
  });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.903",
3
+ "version": "0.1.0-alpha.904",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.903",
9
+ "version": "0.1.0-alpha.904",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.903",
3
+ "version": "0.1.0-alpha.904",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },