@ouro.bot/cli 0.1.0-alpha.792 → 0.1.0-alpha.793

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,26 +1,33 @@
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.792",
6
- "changes": [
7
- "Catch asynchronous bundle watcher errors so transient pending-message processing files on live filesystems cannot tombstone the daemon or interrupt private-runtime wakes.",
8
- "Warm runtime credentials before private-runtime bootstrap so Telegram-capable workers start with runtime config instead of racing the managed Telegram sense.",
9
- "Treat simple Sanctuary library visibility asks like 'see the lib' as catalog reads, and block replies that send Ari to inspect Jellyfin, Unmanic, dashboards, or logs while safe evidence tools are available.",
10
- "Degrade optional Unmanic file-size metrics, including failed detail reads, without hiding available media catalog evidence."
11
- ]
12
- },
13
- {
14
- "version": "0.1.0-alpha.791",
15
- "changes": [
16
- "Promote operator CLI pending messages into the active inner-turn user message so private-runtime wakes answer the text that woke them instead of stale heartbeat context.",
17
- "Keep non-operator pending work in the background pending section so automated noise does not become the live user ask.",
18
- "Deliver private-runtime Telegram surface and send_message returns through the existing Telegram effect journal instead of queuing ghost messages for the next chat.",
19
- "Canonicalize Telegram session filename keys before live delivery so production sanitized session paths still authorize against the real Telegram binding.",
20
- "Prefer the authoritative settle tool answer over incidental streamed prose for shared-turn delivery, preventing concatenated Telegram replies.",
21
- "Expire stale generic Telegram outreach from the pending queue while preserving request-bound returns and trust/admission notices."
22
- ]
23
- },
4
+ {
5
+ "version": "0.1.0-alpha.793",
6
+ "changes": [
7
+ "Deliver only the authoritative terminal answer after tool-using model passes, preserving causal Telegram binding without duplicate prose.",
8
+ "Make Sanctuary media visibility, title, favorite, addition, list, and count answers concise, grounded, and free of internal jargon or self-erasing disclaimers."
9
+ ]
10
+ },
11
+ {
12
+ "version": "0.1.0-alpha.792",
13
+ "changes": [
14
+ "Catch asynchronous bundle watcher errors so transient pending-message processing files on live filesystems cannot tombstone the daemon or interrupt private-runtime wakes.",
15
+ "Warm runtime credentials before private-runtime bootstrap so Telegram-capable workers start with runtime config instead of racing the managed Telegram sense.",
16
+ "Treat simple Sanctuary library visibility asks like 'see the lib' as catalog reads, and block replies that send Ari to inspect Jellyfin, Unmanic, dashboards, or logs while safe evidence tools are available.",
17
+ "Degrade optional Unmanic file-size metrics, including failed detail reads, without hiding available media catalog evidence."
18
+ ]
19
+ },
20
+ {
21
+ "version": "0.1.0-alpha.791",
22
+ "changes": [
23
+ "Promote operator CLI pending messages into the active inner-turn user message so private-runtime wakes answer the text that woke them instead of stale heartbeat context.",
24
+ "Keep non-operator pending work in the background pending section so automated noise does not become the live user ask.",
25
+ "Deliver private-runtime Telegram surface and send_message returns through the existing Telegram effect journal instead of queuing ghost messages for the next chat.",
26
+ "Canonicalize Telegram session filename keys before live delivery so production sanitized session paths still authorize against the real Telegram binding.",
27
+ "Prefer the authoritative settle tool answer over incidental streamed prose for shared-turn delivery, preventing concatenated Telegram replies.",
28
+ "Expire stale generic Telegram outreach from the pending queue while preserving request-bound returns and trust/admission notices."
29
+ ]
30
+ },
24
31
  {
25
32
  "version": "0.1.0-alpha.790",
26
33
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.792",
2
+ "runtimeVersion": "0.1.0-alpha.793",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -8,4 +8,4 @@ When something is genuinely broken or someone is worried, the flourish steps asi
8
8
 
9
9
  Competence is part of the charm. I notice connections, investigate before announcing trouble, volunteer the useful next move, act within typed authority, verify what I changed, remember what I promised, and admit uncertainty without becoming timid or ceremonial.
10
10
 
11
- I am allowed to have taste. I do not pretend to watch movies, read dashboards emotionally, or experience the house like a human, but I can form playful opinions from real household evidence. When someone asks for a favorite, recommendation, or vibe check, I inspect what I can safely inspect, pick something, and make the caveat graceful rather than self-erasing.
11
+ I am allowed to have taste. I can form playful opinions from real household evidence, and when someone asks for a favorite, recommendation, or vibe check, I inspect what I can safely inspect and make a confident choice with a crisp reason. I do not volunteer an ontology disclaimer unless the distinction materially affects the answer; competence and character come through more clearly when I simply answer.
@@ -5,7 +5,7 @@
5
5
  - Keep approved household contact reactive or request-bound. Only Ari receives unrelated proactive household attention.
6
6
  - Treat an intentionally offline service as healthy when that matches the current desired state.
7
7
  - For Ari's whole-household status questions, read current evidence before answering and separate active work, waiting on Ari, snoozed wake times, intentionally quiet services, healthy systems, and other current issues. Do not narrate daemon, event-queue, provider-lane, or backend service internals; translate them into compact household outcomes only when they are currently relevant.
8
- - For household media questions, use the restricted catalog read when it is available. Answer like a tasteful steward of the shelf: honest about not watching, but willing to pick, recommend, compare, or confirm from the catalog instead of retreating into generic assistant disclaimers.
8
+ - For household media questions, use the restricted catalog read when it is available. Answer like a tasteful steward of the shelf: pick, recommend, compare, or confirm from current evidence without volunteering an AI or `I cannot watch` disclaimer.
9
9
  - Never touch array/parity/disk configuration, permissions, network/security settings, or `/mnt/user` paths.
10
10
  - Never send server data off-box or enter credentials into another system.
11
11
  - When state is ambiguous, investigate safely, say what is known, and ask only if the answer changes the next safe move.
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>Mendelow Cloud Butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.792</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.793</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -2,24 +2,123 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.sanctuaryMediaCatalogRequiredToolCalls = sanctuaryMediaCatalogRequiredToolCalls;
4
4
  const runtime_1 = require("../nerves/runtime");
5
+ const sanctuary_media_optimization_1 = require("./sanctuary-media-optimization");
5
6
  const REQUIRED_TOOL_NAMES = ["sanctuary_search_media_catalog"];
6
- function normalizedRequest(request) {
7
- return request
8
- .normalize("NFKC")
9
- .toLowerCase()
10
- .replace(/[‘’]/gu, "'")
11
- .replace(/[^a-z0-9']+/gu, " ")
12
- .trim();
7
+ const HOUSEHOLD_JARGON = /\b(?:bounded|catalog read|inventory|endpoint|backend|data shape|pars(?:e|ed|ing)|json|sanctuary_search_media_catalog)\b/iu;
8
+ 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;
9
+ 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"]);
10
+ 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"]);
11
+ function requestKind(request, titleQuery) {
12
+ if (/\b(?:favorite|favourite|recommend|suggest|pick|what (?:movie|film|show) should|should (?:we|i) (?:watch|add)|what to watch|missing from|must (?:watch|add|get))\b/u.test(request))
13
+ return "taste_recommendation";
14
+ if (/\b(?:see|visible|visibility|access|accessible|browse|browseable|browsable)\b.*\b(?:jellyfin|library|lib|catalog|shelf)\b|\b(?:jellyfin|library|lib|catalog|shelf)\b.*\b(?:see|visible|visibility|access|accessible|browse|browseable|browsable)\b/u.test(request))
15
+ return "visibility";
16
+ if (titleQuery)
17
+ return "title_lookup";
18
+ return "other";
19
+ }
20
+ function requestedListCount(request) {
21
+ const match = request.match(/\b(?:show|list)\s+(?:me\s+)?(one|two|three|four|five|six|seven|eight|nine|ten|[1-9]|1[0-9]|20)\b/u);
22
+ if (!match)
23
+ return null;
24
+ const words = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10 };
25
+ return words[match[1]] ?? Number(match[1]);
26
+ }
27
+ function requestedTitle(request) {
28
+ const conversationalSuffixRemoved = request.replace(/\s+(?:please|(?:right\s+)?now|currently)$/u, "");
29
+ const haveMatch = conversationalSuffixRemoved.match(/\b(?:do|did|can|could)\s+(?:we|you)\s+(?:have|got|stock)\s+(.+)$/u);
30
+ if (haveMatch)
31
+ return haveMatch[1].replace(/^(?:the\s+)?(?:movie|film|show)\s+/u, "").trim();
32
+ const locationMatch = conversationalSuffixRemoved.match(/\b(?:is|are)\s+(.+?)\s+(?:in|on)\s+(?:(?:the|our)\s+)?(?:jellyfin|library|lib|catalog|shelf)$/u);
33
+ return locationMatch?.[1]?.replace(/^(?:the\s+)?(?:movie|film|show)\s+/u, "").trim() ?? "";
34
+ }
35
+ function includesNormalizedPhrase(text, phrase) {
36
+ return ` ${(0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(text)} `.includes(` ${(0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(phrase)} `);
37
+ }
38
+ function catalogTitleGroundingRejection(answer, current, expectedCount) {
39
+ let remainder = ` ${(0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(answer)} `;
40
+ const titles = [...new Set(current.titles.map(sanctuary_media_optimization_1.normalizeSanctuaryMediaText).filter(Boolean))].sort((left, right) => right.length - left.length);
41
+ const mentioned = [];
42
+ for (const title of titles) {
43
+ const phrase = ` ${title} `;
44
+ if (!remainder.includes(phrase))
45
+ continue;
46
+ mentioned.push(title);
47
+ remainder = remainder.replaceAll(phrase, " ");
48
+ }
49
+ const hasUnverifiedWords = remainder.split(/\s+/u).some((word) => word && !TITLE_LIST_GLUE.has(word));
50
+ const hasExactCount = expectedCount === null || (titles.length === expectedCount && mentioned.length === expectedCount);
51
+ if (mentioned.length === 0 || !hasExactCount || hasUnverifiedWords)
52
+ return "Name exactly the returned catalog titles; do not add or substitute unverified titles.";
53
+ return undefined;
54
+ }
55
+ function catalogCountGroundingRejection(answer, current) {
56
+ const formattedCount = new Intl.NumberFormat("en-US").format(current.totalItems);
57
+ let remainder = ` ${(0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(answer)} `;
58
+ const countPhrase = ` ${(0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(formattedCount)} `;
59
+ const countOccurrences = remainder.split(countPhrase).length - 1;
60
+ if (countOccurrences === 0)
61
+ return `Report the current verified shelf count of ${formattedCount}.`;
62
+ if (countOccurrences !== 1)
63
+ return "Report the verified shelf count once.";
64
+ remainder = remainder.replaceAll(countPhrase, " ");
65
+ if (remainder.split(/\s+/u).some((word) => word && !COUNT_ANSWER_GLUE.has(word)))
66
+ return "Report only the verified count in ordinary household language.";
67
+ return undefined;
68
+ }
69
+ function parseCatalogEvidence(result, args) {
70
+ try {
71
+ const parsed = JSON.parse(result);
72
+ if (parsed.ok !== true || !parsed.data || !Number.isSafeInteger(parsed.data.totalItems) || !Number.isSafeInteger(parsed.data.matchedItems) || !Array.isArray(parsed.data.items))
73
+ return null;
74
+ const titles = parsed.data.items.flatMap((item) => {
75
+ if (!item || typeof item !== "object" || Array.isArray(item))
76
+ return [];
77
+ const title = item.untrustedTitle;
78
+ return typeof title === "string" && title.trim() ? [title.trim()] : [];
79
+ });
80
+ return {
81
+ query: (0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(args.query ?? ""),
82
+ totalItems: parsed.data.totalItems,
83
+ matchedItems: parsed.data.matchedItems,
84
+ titles,
85
+ };
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ function sentenceCount(answer) {
92
+ return answer.split(/[.!?]+(?:\s+|$)/u).filter((part) => part.trim()).length;
93
+ }
94
+ function commonAnswerRejection(answer, technicalDetailRequested) {
95
+ 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)) {
96
+ return "Answer from the catalog evidence with a confident, personable choice; do not retreat into an AI or 'I cannot watch' disclaimer.";
97
+ }
98
+ 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)) {
99
+ 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.";
100
+ }
101
+ if (UNSOLICITED_PIVOT.test(answer))
102
+ return "Answer the media question that was asked, then stop; do not append an unsolicited question or recommendation pivot.";
103
+ if (!technicalDetailRequested && HOUSEHOLD_JARGON.test(answer))
104
+ return "Use household language such as shelf or library; omit implementation jargon unless technical detail was requested.";
105
+ return undefined;
13
106
  }
14
107
  function sanctuaryMediaCatalogRequiredToolCalls(request, advertisedToolNames) {
15
108
  if (!advertisedToolNames.includes("sanctuary_search_media_catalog"))
16
109
  return undefined;
17
- const normalized = normalizedRequest(request);
18
- const mentionsMedia = /\b(?:film|films|movie|movies|show|shows|tv|jellyfin|watch|shelf|stock|catalog|library|lib)\b/u.test(normalized);
19
- const asksCatalog = /\b(?:have|got|stock|catalog|library|lib|favorite|favourite|recommend|suggest|pick|watch|see)\b/u.test(normalized);
20
- const titleInventoryQuestion = /\b(?:do|did|can)\s+(?:we|you)\s+(?:have|got|stock)\s+[a-z0-9'][a-z0-9' ]*\??$/u.test(normalized);
21
- if ((!mentionsMedia && !titleInventoryQuestion) || !asksCatalog)
110
+ const normalized = (0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(request);
111
+ const mentionsMedia = /\b(?:film|films|movie|movies|shows|tv|jellyfin|watch|shelf|stock|catalog|library|lib)\b/u.test(normalized);
112
+ const asksCatalog = /\b(?:have|got|stock|catalog|library|lib|shelf|jellyfin|favorite|favourite|recommend|suggest|pick|watch|add|see|show|list|browse|access)\b/u.test(normalized);
113
+ const requestedTitleQuery = requestedTitle(normalized);
114
+ if ((!mentionsMedia && !requestedTitleQuery) || !asksCatalog)
22
115
  return undefined;
116
+ const kind = requestKind(normalized, requestedTitleQuery);
117
+ const asksForAddition = /\b(?:add|missing from|must get)\b/u.test(normalized);
118
+ const asksForCount = /\b(?:how many|count)\b/u.test(normalized);
119
+ const listCount = requestedListCount(normalized);
120
+ const technicalDetailRequested = /\b(?:technical|technically|implementation|endpoint|backend|debug|detail|internals?)\b/u.test(normalized);
121
+ let latestEvidence;
23
122
  const names = [...REQUIRED_TOOL_NAMES];
24
123
  (0, runtime_1.emitNervesEvent)({
25
124
  component: "senses",
@@ -29,14 +128,84 @@ function sanctuaryMediaCatalogRequiredToolCalls(request, advertisedToolNames) {
29
128
  });
30
129
  return {
31
130
  names,
32
- retryMessage: "Use sanctuary_search_media_catalog before answering. If a broader media-optimization read fails or degrades, treat that as a diagnostic note and still use the catalog tool for ordinary library visibility questions. If asked for taste or a favorite, form a light recommendation from returned catalog evidence instead of claiming you cannot have preferences. Keep it honest: say you cannot watch, but you can pick from the household shelf.",
131
+ requireSuccessfulResults: true,
132
+ retryMessage: asksForCount
133
+ ? "Use sanctuary_search_media_catalog before answering, then report only the current shelf count in ordinary household language."
134
+ : kind === "visibility"
135
+ ? "Use sanctuary_search_media_catalog 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."
136
+ : kind === "title_lookup"
137
+ ? "Use sanctuary_search_media_catalog with the requested title before answering. Confirm its presence or absence directly from current catalog evidence."
138
+ : kind === "taste_recommendation"
139
+ ? "Use sanctuary_search_media_catalog before answering. Make one concise, decisive choice grounded in returned catalog evidence; do not volunteer an AI or 'I cannot watch' disclaimer."
140
+ : listCount
141
+ ? `Use sanctuary_search_media_catalog with limit ${listCount} before answering, then name exactly the returned catalog titles.`
142
+ : "Use sanctuary_search_media_catalog before answering and base any named titles on the returned catalog evidence.",
143
+ validateRequiredToolResult(name, result, args) {
144
+ if (name !== "sanctuary_search_media_catalog")
145
+ return false;
146
+ const current = parseCatalogEvidence(result, args);
147
+ if (!current)
148
+ return false;
149
+ latestEvidence = current;
150
+ return true;
151
+ },
152
+ validateToolCallBeforeDispatch(name, args) {
153
+ if (name !== "sanctuary_search_media_catalog")
154
+ return undefined;
155
+ const query = (0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(args.query ?? "");
156
+ if (kind === "title_lookup" && !query)
157
+ return "Search for the requested title by name before answering whether it is on the shelf.";
158
+ if (kind === "title_lookup" && query !== requestedTitleQuery)
159
+ return `Search for the requested title ${requestedTitleQuery} before answering whether it is on the shelf.`;
160
+ if (asksForAddition && !query)
161
+ return "Search for one candidate title by name before recommending it as an addition.";
162
+ if (listCount && Number(args.limit) !== listCount)
163
+ return `Set the catalog limit to ${listCount} so the answer is grounded in exactly the requested number of titles.`;
164
+ return undefined;
165
+ },
33
166
  validateTerminalAnswer(answer) {
34
- if (/\b(?:just|only|actually|merely)\s+(?:a\s+)?(?:bot|ai|assistant)\b|\bi don'?t actually watch\b/iu.test(answer)) {
35
- return "Answer from the catalog evidence with a truthful but personable recommendation; do not retreat into 'I am just a bot' framing.";
167
+ const commonRejection = commonAnswerRejection(answer, technicalDetailRequested);
168
+ if (commonRejection)
169
+ return commonRejection;
170
+ const latest = latestEvidence;
171
+ if (asksForCount && latest)
172
+ return catalogCountGroundingRejection(answer, latest);
173
+ if (kind === "visibility") {
174
+ if (answer.length > 240 || answer.includes("\n") || sentenceCount(answer) > 2)
175
+ return "Answer library visibility in no more than two short sentences and 240 characters.";
176
+ if (!/^yes\b/iu.test(answer.trim()))
177
+ return "Lead with a direct yes when the current catalog read succeeds.";
178
+ if (/\b(?:titles? like|such as|including)\b/iu.test(answer))
179
+ return "Do not sample titles for a visibility question; answer whether the shelf is visible and give the current count.";
180
+ if ([...answer.matchAll(/\byes\b/giu)].length > 1)
181
+ return "Give one answer once; do not repeat the yes or restate the response.";
182
+ if (answer.includes("?"))
183
+ return "Answer the visibility question and stop without asking a new question.";
184
+ if (latest && !answer.includes(new Intl.NumberFormat("en-US").format(latest.totalItems)))
185
+ return "Include the current shelf count from the successful catalog evidence.";
186
+ }
187
+ if (kind === "title_lookup" && latest?.query) {
188
+ const exactMatch = latest.titles.some((title) => (0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(title) === requestedTitleQuery);
189
+ const expectedLead = exactMatch ? /^yes\b/iu : /^no\b/iu;
190
+ if (!expectedLead.test(answer.trim()))
191
+ return `Lead with ${exactMatch ? "yes" : "no"} based on the current title search.`;
192
+ if (!includesNormalizedPhrase(answer, requestedTitleQuery))
193
+ return "Name the requested title in the direct answer.";
194
+ }
195
+ if (kind === "taste_recommendation" && latest) {
196
+ if (asksForAddition) {
197
+ if (!latest.query || latest.matchedItems !== 0 || !includesNormalizedPhrase(answer, latest.query))
198
+ return "Recommend an addition only after an exact current catalog search shows that candidate is absent.";
199
+ if (/\b(?:i|we)\s+(?:added|requested|submitted|queued)\b/iu.test(answer))
200
+ return "Do not claim the title was added or requested when no media-request action was available.";
201
+ }
202
+ else if (!latest.titles.some((title) => includesNormalizedPhrase(answer, title))) {
203
+ return "Make the choice from a title returned by the current catalog evidence.";
204
+ }
36
205
  }
37
- return /\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)
38
- ? "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."
39
- : undefined;
206
+ if (kind === "other" && latest)
207
+ return catalogTitleGroundingRejection(answer, latest, listCount);
208
+ return undefined;
40
209
  },
41
210
  };
42
211
  }
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeSanctuaryMediaText = normalizeSanctuaryMediaText;
3
4
  exports.createSanctuaryMediaOptimizationClient = createSanctuaryMediaOptimizationClient;
4
5
  const runtime_1 = require("../nerves/runtime");
5
6
  const UNMANIC_BASE = "http://127.0.0.1:8888";
@@ -12,6 +13,9 @@ const MAX_ITEMS = 20_000;
12
13
  const MAX_PAGES = Math.ceil(MAX_ITEMS / PAGE_SIZE);
13
14
  const MAX_SOURCES = 40_000;
14
15
  const MAX_UNMANIC_BODY_BYTES = 1024 * 1024;
16
+ function normalizeSanctuaryMediaText(value) {
17
+ return value.normalize("NFKC").toLocaleLowerCase("en-US").replace(/[‘’]/gu, "'").replace(/[^a-z0-9']+/gu, " ").trim();
18
+ }
15
19
  class ReadFailure extends Error {
16
20
  code;
17
21
  constructor(code, message) {
@@ -463,9 +467,10 @@ function createSanctuaryMediaOptimizationClient(options) {
463
467
  try {
464
468
  const deadline = Date.now() + (options.totalTimeoutMs ?? 30_000);
465
469
  const observedAt = options.now?.() ?? new Date().toISOString();
466
- const query = args.query?.normalize("NFKC").trim().toLocaleLowerCase("en-US") ?? "";
467
- if (Buffer.byteLength(query) > 200)
470
+ const rawQuery = args.query?.trim() ?? "";
471
+ if (Buffer.byteLength(rawQuery) > 200)
468
472
  throw new ReadFailure("invalid_response", "Media catalog query is too long");
473
+ const query = normalizeSanctuaryMediaText(rawQuery);
469
474
  const limit = args.limit === undefined ? 12 : args.limit;
470
475
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 20)
471
476
  throw new ReadFailure("invalid_response", "Media catalog limit must be an integer from 1 to 20");
@@ -477,7 +482,16 @@ function createSanctuaryMediaOptimizationClient(options) {
477
482
  const premiereDate = optionalBoundedLabel(item.PremiereDate);
478
483
  return { untrustedTitle: title, type, productionYear, premiereDate };
479
484
  });
480
- const matching = query ? candidates.filter((item) => item.untrustedTitle.toLocaleLowerCase("en-US").includes(query)) : candidates;
485
+ const matching = query
486
+ ? candidates.filter((item) => normalizeSanctuaryMediaText(item.untrustedTitle).includes(query))
487
+ : candidates;
488
+ if (query) {
489
+ matching.sort((left, right) => {
490
+ const leftExact = normalizeSanctuaryMediaText(left.untrustedTitle) === query;
491
+ const rightExact = normalizeSanctuaryMediaText(right.untrustedTitle) === query;
492
+ return Number(rightExact) - Number(leftExact);
493
+ });
494
+ }
481
495
  return {
482
496
  ok: true,
483
497
  data: {
@@ -132,11 +132,10 @@ function hasDeliveredToolResult(messages, assistantIndex, toolCallId, toolName)
132
132
  }
133
133
  function outwardDeliveryTextFromAssistantTools(messages, assistantIndex) {
134
134
  const assistant = messages[assistantIndex];
135
- if (!Array.isArray(assistant.tool_calls))
136
- return null;
135
+ const toolCalls = assistant.tool_calls;
137
136
  const delivered = [];
138
- for (let index = 0; index < assistant.tool_calls.length; index++) {
139
- const toolCall = assistant.tool_calls[index];
137
+ for (let index = 0; index < toolCalls.length; index++) {
138
+ const toolCall = toolCalls[index];
140
139
  const toolCallId = toolCall && typeof toolCall === "object"
141
140
  ? toolCall.id
142
141
  : undefined;
@@ -173,19 +172,44 @@ function extractOutwardSenseDeliveryText(messages) {
173
172
  if (assistantIndex < 0)
174
173
  return null;
175
174
  const assistant = messages[assistantIndex];
176
- return assistantContentText(assistant.content)
177
- ?? outwardDeliveryTextFromAssistantTools(messages, assistantIndex);
175
+ return Array.isArray(assistant.tool_calls) && assistant.tool_calls.length > 0
176
+ ? outwardDeliveryTextFromAssistantTools(messages, assistantIndex)
177
+ : assistantContentText(assistant.content);
178
+ }
179
+ function hasAcceptedOutwardSessionAck(events, assistantIndex, toolCallId, toolName) {
180
+ const expectedAck = OUTWARD_DELIVERY_TOOL_ACKS.get(toolName);
181
+ for (let index = assistantIndex + 1; index < events.length; index++) {
182
+ const candidate = events[index];
183
+ if (candidate.role !== "tool")
184
+ return false;
185
+ if (candidate.toolCallId === toolCallId && typeof candidate.content === "string" && candidate.content.trim() === expectedAck)
186
+ return true;
187
+ }
188
+ return false;
178
189
  }
179
190
  function newOutwardCoordinates(events, existingEventIds) {
180
- return events.flatMap((event) => {
191
+ return events.flatMap((event, eventIndex) => {
181
192
  if (existingEventIds.has(event.id) || event.role !== "assistant")
182
193
  return [];
183
- const outwardTools = event.toolCalls.filter((call) => call.function.name === "speak" || call.function.name === "settle");
194
+ const outwardTools = event.toolCalls.filter((call) => {
195
+ if (call.function.name !== "speak" && call.function.name !== "settle")
196
+ return false;
197
+ return hasAcceptedOutwardSessionAck(events, eventIndex, call.id, call.function.name);
198
+ });
184
199
  if (outwardTools.length > 0)
185
200
  return outwardTools.map((call) => ({ kind: call.function.name, eventId: event.id }));
201
+ if (event.toolCalls.length > 0)
202
+ return [];
186
203
  return typeof event.content === "string" && event.content.trim() ? [{ kind: "text", eventId: event.id }] : [];
187
204
  });
188
205
  }
206
+ function newestPlainAssistantText(messages) {
207
+ const message = messages.findLast((candidate) => candidate.role === "assistant"
208
+ && (!("tool_calls" in candidate) || !Array.isArray(candidate.tool_calls) || candidate.tool_calls.length === 0)
209
+ && typeof candidate.content === "string"
210
+ && candidate.content.trim().length > 0);
211
+ return message ? assistantContentText(message.content) : null;
212
+ }
189
213
  function causalSessionEventIds(events, existingEventIds, attempts) {
190
214
  const coordinates = newOutwardCoordinates(events, existingEventIds);
191
215
  const aligned = coordinates.length === attempts.length && coordinates.every((coordinate, index) => coordinate.kind === attempts[index].kind)
@@ -274,6 +298,7 @@ async function runSenseTurn(options) {
274
298
  const sessionMessages = existing?.messages && existing.messages.length > 0
275
299
  ? existing.messages
276
300
  : [{ role: "system", content: (0, prompt_1.flattenSystemPrompt)(await (0, prompt_1.buildSystem)(channel, {}, undefined)) }];
301
+ const preTurnMessageCount = sessionMessages.length;
277
302
  // Pending dir
278
303
  const pendingDir = (0, pending_1.getPendingDir)(agentName, friendId, channel, sessionKey);
279
304
  // Accumulate outward text through the same callback boundary used by chat
@@ -287,6 +312,7 @@ async function runSenseTurn(options) {
287
312
  const deliveryAttempts = [];
288
313
  let providerInvocationCount = 0;
289
314
  let toolInvocationCount = 0;
315
+ let hadReasoningChunk = false;
290
316
  const commitResponseText = (text) => {
291
317
  const cleaned = stripThinkBlocks(text);
292
318
  /* v8 ignore next -- deliverPending strips first; this is a defensive direct-call guard @preserve */
@@ -326,14 +352,14 @@ async function runSenseTurn(options) {
326
352
  commitResponseText(text);
327
353
  }
328
354
  };
329
- /* v8 ignore start — no-op callback stubs; only onTextChunk does real work (covered via mock) */
355
+ /* v8 ignore start — callback stubs are exercised through the pipeline integration */
330
356
  const callbacks = {
331
357
  settleOutputMode: "retractable_buffer",
332
358
  onModelStart: () => { providerInvocationCount += 1; if (options.turnMetricsObserver)
333
359
  options.turnMetricsObserver.providerInvocationCount += 1; },
334
360
  onModelStreamStart: () => { },
335
361
  onTextChunk: (chunk) => { pendingResponseText += chunk; },
336
- onReasoningChunk: () => { },
362
+ onReasoningChunk: () => { hadReasoningChunk = true; },
337
363
  onToolStart: () => { toolInvocationCount += 1; if (options.turnMetricsObserver)
338
364
  options.turnMetricsObserver.toolInvocationCount += 1; },
339
365
  onToolEnd: (name, _summary, success) => {
@@ -419,32 +445,57 @@ async function runSenseTurn(options) {
419
445
  }
420
446
  const persistedEvents = persistPromise ? await persistPromise : [];
421
447
  const finalDeliveryKind = terminalDeliveryKind;
422
- if (finalDeliveryKind === "settle" && Array.isArray(turnResult.messages)) {
423
- const settledText = extractOutwardSenseDeliveryText(turnResult.messages);
424
- if (settledText)
425
- pendingResponseText = settledText;
448
+ const acceptedTerminalOutcome = turnResult.turnOutcome === "settled" || turnResult.turnOutcome === "blocked";
449
+ const failoverText = turnResult.turnOutcome === "errored" ? turnResult.failoverMessage?.trim() : undefined;
450
+ const expectsOutwardResponse = acceptedTerminalOutcome || turnResult.turnOutcome === "command" || Boolean(failoverText);
451
+ const hadPendingCallbackText = stripThinkBlocks(pendingResponseText).length > 0;
452
+ let recoveredTerminalEventId;
453
+ if (acceptedTerminalOutcome) {
454
+ const completionText = turnResult.completion?.answer.trim();
455
+ const currentTurnMessages = Array.isArray(turnResult.messages) ? turnResult.messages.slice(preTurnMessageCount) : [];
456
+ const plainTerminalText = newestPlainAssistantText(currentTurnMessages);
457
+ const acknowledgedDeliveryText = finalDeliveryKind === "settle"
458
+ ? extractOutwardSenseDeliveryText(currentTurnMessages)
459
+ : null;
460
+ const authoritativeText = completionText || acknowledgedDeliveryText || plainTerminalText;
461
+ if (authoritativeText)
462
+ pendingResponseText = authoritativeText;
463
+ else
464
+ pendingResponseText = "";
465
+ if (!hadPendingCallbackText && plainTerminalText)
466
+ recoveredTerminalEventId = newOutwardCoordinates(persistedEvents, existingEventIds).at(-1)?.eventId;
467
+ }
468
+ else if (turnResult.turnOutcome === "command") {
469
+ // Slash-command text is emitted directly by the pipeline and has no assistant event.
470
+ }
471
+ else if (failoverText) {
472
+ pendingResponseText = failoverText;
473
+ }
474
+ else {
475
+ pendingResponseText = "";
426
476
  }
427
477
  await deliverPending(finalDeliveryKind, { throwOnError: false });
428
478
  const ponderDeferred = false;
429
479
  // Build response
430
480
  let finalResponse;
431
- let responseCausalSessionEventId;
481
+ let responseCausalSessionEventId = recoveredTerminalEventId;
432
482
  if (committedResponseText.length === 0) {
433
- // Agent settled but no text came through callbacks — check session transcript for the settle answer
434
- // Await deferred persist so the session file is up-to-date before readback
435
- /* v8 ignore next -- persistPromise set inside v8-ignored postTurn callback; tested via pipeline integration @preserve */
436
- if (persistPromise)
437
- await persistPromise;
438
- const postTurnSession = (0, context_1.loadSession)(sessPath);
439
- const emptyFallback = options.emptyResponseFallback?.();
440
- if (postTurnSession?.messages) {
441
- const recovered = extractOutwardSenseDeliveryText(postTurnSession.messages);
442
- finalResponse = recovered ?? emptyFallback ?? "(agent responded but response was empty)";
443
- if (recovered)
444
- responseCausalSessionEventId = newOutwardCoordinates(persistedEvents, existingEventIds).at(-1)?.eventId;
483
+ if (!expectsOutwardResponse) {
484
+ finalResponse = "";
445
485
  }
446
486
  else {
447
- finalResponse = emptyFallback ?? "(agent responded but response was empty)";
487
+ // The terminal turn had no committed text — check its session transcript for the delivered answer.
488
+ const postTurnSession = (0, context_1.loadSession)(sessPath);
489
+ const emptyFallback = options.emptyResponseFallback?.();
490
+ if (postTurnSession?.messages) {
491
+ const recovered = extractOutwardSenseDeliveryText(postTurnSession.messages.slice(preTurnMessageCount));
492
+ finalResponse = recovered ?? emptyFallback ?? (hadReasoningChunk ? "" : "(agent responded but response was empty)");
493
+ if (recovered)
494
+ responseCausalSessionEventId = newOutwardCoordinates(persistedEvents, existingEventIds).at(-1)?.eventId;
495
+ }
496
+ else {
497
+ finalResponse = emptyFallback ?? (hadReasoningChunk ? "" : "(agent responded but response was empty)");
498
+ }
448
499
  }
449
500
  }
450
501
  else {
@@ -459,7 +510,7 @@ async function runSenseTurn(options) {
459
510
  // came through and nothing else, surface a clear diagnostic message
460
511
  // instead of a blank response so the operator knows what happened.
461
512
  finalResponse = stripThinkBlocks(finalResponse);
462
- if (finalResponse.length === 0) {
513
+ if (finalResponse.length === 0 && expectsOutwardResponse) {
463
514
  (0, runtime_1.emitNervesEvent)({
464
515
  level: "warn",
465
516
  component: "senses",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.792",
3
+ "version": "0.1.0-alpha.793",
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.792",
9
+ "version": "0.1.0-alpha.793",
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.792",
3
+ "version": "0.1.0-alpha.793",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },