@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 +6 -0
- package/deploy/unraid/sanctuary-replay-gate.mjs +26 -1
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +3 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/core.js +63 -13
- package/dist/heart/reply-style.js +1 -5
- package/dist/heart/shelf-claims.js +78 -0
- package/dist/senses/sanctuary-media-catalog-contract.js +5 -43
- package/dist/senses/sanctuary-runtime.js +2 -2
- package/dist/senses/telegram-handoff.js +80 -0
- package/dist/senses/telegram.js +57 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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.
|
|
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>
|
package/dist/heart/core.js
CHANGED
|
@@ -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 <
|
|
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:
|
|
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?.
|
|
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(
|
|
1823
|
-
pushGenerated({ ...msg, content:
|
|
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,
|
|
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 =
|
|
2090
|
+
const finalAnswer = replacement ?? deliveredAnswer;
|
|
2091
|
+
const rewritten = replacement !== null;
|
|
2042
2092
|
try {
|
|
2043
|
-
if (
|
|
2093
|
+
if (rewritten && result.settleStreamed)
|
|
2044
2094
|
callbacks.onClearText?.();
|
|
2045
|
-
if (
|
|
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
|
-
:
|
|
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
|
+
}
|
package/dist/senses/telegram.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
});
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
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",
|