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

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.
@@ -2,24 +2,132 @@
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|inventory|endpoint|backend|data shape|pars(?:e|ed|ing)|json|jellyfin|unmanic|sanctuary_search_media_catalog)\b/iu;
8
+ const NEGATED_MEDIA_ACCESS = /\b(?:not|never|none|nothing|zero|inaccessible|invisible|unavailable)\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;
9
+ 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;
10
+ 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"]);
11
+ 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"]);
12
+ function requestKind(request, titleQuery) {
13
+ 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))
14
+ return "taste_recommendation";
15
+ 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))
16
+ return "visibility";
17
+ if (titleQuery)
18
+ return "title_lookup";
19
+ return "other";
20
+ }
21
+ function requestedListCount(request) {
22
+ 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);
23
+ if (!match)
24
+ return null;
25
+ const words = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10 };
26
+ return words[match[1]] ?? Number(match[1]);
27
+ }
28
+ function requestedTitle(request) {
29
+ const conversationalSuffixRemoved = request.replace(/\s+(?:please|(?:right\s+)?now|currently)$/u, "");
30
+ const haveMatch = conversationalSuffixRemoved.match(/\b(?:do|did|can|could)\s+(?:we|you)\s+(?:have|got|stock)\s+(.+)$/u);
31
+ if (haveMatch)
32
+ return haveMatch[1].replace(/^(?:the\s+)?(?:movie|film|show)\s+/u, "").trim();
33
+ const locationMatch = conversationalSuffixRemoved.match(/\b(?:is|are)\s+(.+?)\s+(?:in|on)\s+(?:(?:the|our)\s+)?(?:jellyfin|library|lib|catalog|shelf)$/u);
34
+ return locationMatch?.[1]?.replace(/^(?:the\s+)?(?:movie|film|show)\s+/u, "").trim() ?? "";
35
+ }
36
+ function includesNormalizedPhrase(text, phrase) {
37
+ return ` ${(0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(text)} `.includes(` ${(0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(phrase)} `);
38
+ }
39
+ function catalogTitleGroundingRejection(answer, current, expectedCount) {
40
+ let remainder = ` ${(0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(answer)} `;
41
+ const titles = [...new Set(current.titles.map(sanctuary_media_optimization_1.normalizeSanctuaryMediaText).filter(Boolean))].sort((left, right) => right.length - left.length);
42
+ const mentioned = [];
43
+ for (const title of titles) {
44
+ const phrase = ` ${title} `;
45
+ if (!remainder.includes(phrase))
46
+ continue;
47
+ mentioned.push(title);
48
+ remainder = remainder.replaceAll(phrase, " ");
49
+ }
50
+ const hasUnverifiedWords = remainder.split(/\s+/u).some((word) => word && !TITLE_LIST_GLUE.has(word));
51
+ const hasExactCount = expectedCount === null || (titles.length === expectedCount && mentioned.length === expectedCount);
52
+ if (mentioned.length === 0 || !hasExactCount || hasUnverifiedWords)
53
+ return "Name exactly the returned catalog titles; do not add or substitute unverified titles.";
54
+ return undefined;
55
+ }
56
+ function catalogCountGroundingRejection(answer, current) {
57
+ const formattedCount = new Intl.NumberFormat("en-US").format(current.totalItems);
58
+ let remainder = ` ${(0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(answer)} `;
59
+ const countPhrase = ` ${(0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(formattedCount)} `;
60
+ const countOccurrences = remainder.split(countPhrase).length - 1;
61
+ if (countOccurrences === 0)
62
+ return `Report the current verified shelf count of ${formattedCount}.`;
63
+ if (countOccurrences !== 1)
64
+ return "Report the verified shelf count once.";
65
+ remainder = remainder.replaceAll(countPhrase, " ");
66
+ if (remainder.split(/\s+/u).some((word) => word && !COUNT_ANSWER_GLUE.has(word)))
67
+ return "Report only the verified count in ordinary household language.";
68
+ return undefined;
69
+ }
70
+ function parseCatalogEvidence(result, args) {
71
+ try {
72
+ const parsed = JSON.parse(result);
73
+ if (parsed.ok !== true || !parsed.data || !Number.isSafeInteger(parsed.data.totalItems) || !Number.isSafeInteger(parsed.data.matchedItems) || !Array.isArray(parsed.data.items))
74
+ return null;
75
+ const titles = parsed.data.items.flatMap((item) => {
76
+ if (!item || typeof item !== "object" || Array.isArray(item))
77
+ return [];
78
+ const title = item.untrustedTitle;
79
+ return typeof title === "string" && title.trim() ? [title.trim()] : [];
80
+ });
81
+ return {
82
+ query: (0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(args.query ?? ""),
83
+ totalItems: parsed.data.totalItems,
84
+ matchedItems: parsed.data.matchedItems,
85
+ titles,
86
+ };
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
92
+ function sentenceCount(answer) {
93
+ return answer.split(/[.!?]+(?:\s+|$)/u).filter((part) => part.trim()).length;
94
+ }
95
+ function hasGroundedFirstPersonVisibility(answer, formattedCount) {
96
+ if (NEGATED_MEDIA_ACCESS.test(answer) || answer.split(formattedCount).length - 1 !== 1)
97
+ return false;
98
+ const escapedCount = formattedCount.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
99
+ const groundedCollection = new RegExp(`\\b(?:i\\s+can\\s+(?:(?:absolutely|clearly|currently|definitely|now|plainly|readily)\\s+)?(?:see|browse|access|view)|i\\s+(?:(?:currently|now)\\s+)?(?:see|browse|access|view)|i[’']m\\s+(?:(?:currently|now)\\s+)?looking\\s+at|i(?:\\s+have|[’']ve\\s+got)(?:\\s+access\\s+to)?)\\s+(?:all\\s+|about\\s+|around\\s+|exactly\\s+|roughly\\s+)?${escapedCount}\\s+(?:titles?|movies?|films?|shows?|episodes?)(?:\\s+(?:on\\s+the\\s+shelf|in\\s+the\\s+library))?\\b`, "iu");
100
+ return answer.split(/[.!?]+(?:\s+|$)/u).some((sentence) => (sentence.includes(formattedCount)
101
+ && groundedCollection.test(sentence)));
102
+ }
103
+ function commonAnswerRejection(answer, technicalDetailRequested) {
104
+ 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)) {
105
+ return "Answer from the catalog evidence with a confident, personable choice; do not retreat into an AI or 'I cannot watch' disclaimer.";
106
+ }
107
+ 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)) {
108
+ 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.";
109
+ }
110
+ if (UNSOLICITED_PIVOT.test(answer))
111
+ return "Answer the media question that was asked, then stop; do not append an unsolicited question or recommendation pivot.";
112
+ if (!technicalDetailRequested && HOUSEHOLD_JARGON.test(answer))
113
+ return "Use household language such as shelf or library; omit implementation jargon unless technical detail was requested.";
114
+ return undefined;
13
115
  }
14
116
  function sanctuaryMediaCatalogRequiredToolCalls(request, advertisedToolNames) {
15
117
  if (!advertisedToolNames.includes("sanctuary_search_media_catalog"))
16
118
  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)
119
+ const normalized = (0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(request);
120
+ const mentionsMedia = /\b(?:film|films|movie|movies|shows|tv|jellyfin|watch|shelf|stock|catalog|library|lib)\b/u.test(normalized);
121
+ 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);
122
+ const requestedTitleQuery = requestedTitle(normalized);
123
+ if ((!mentionsMedia && !requestedTitleQuery) || !asksCatalog)
22
124
  return undefined;
125
+ const kind = requestKind(normalized, requestedTitleQuery);
126
+ const asksForAddition = /\b(?:add|missing from|must get)\b/u.test(normalized);
127
+ const asksForCount = /\b(?:how many|count)\b/u.test(normalized);
128
+ const listCount = requestedListCount(normalized);
129
+ const technicalDetailRequested = /\b(?:technical|technically|implementation|endpoint|backend|debug|detail|internals?)\b/u.test(normalized);
130
+ let latestEvidence;
23
131
  const names = [...REQUIRED_TOOL_NAMES];
24
132
  (0, runtime_1.emitNervesEvent)({
25
133
  component: "senses",
@@ -29,14 +137,84 @@ function sanctuaryMediaCatalogRequiredToolCalls(request, advertisedToolNames) {
29
137
  });
30
138
  return {
31
139
  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.",
140
+ requireSuccessfulResults: true,
141
+ retryMessage: asksForCount
142
+ ? "Use sanctuary_search_media_catalog before answering, then report only the current shelf count in ordinary household language."
143
+ : kind === "visibility"
144
+ ? "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."
145
+ : kind === "title_lookup"
146
+ ? "Use sanctuary_search_media_catalog with the requested title before answering. Confirm its presence or absence directly from current catalog evidence."
147
+ : kind === "taste_recommendation"
148
+ ? "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."
149
+ : listCount
150
+ ? `Use sanctuary_search_media_catalog with limit ${listCount} before answering, then name exactly the returned catalog titles.`
151
+ : "Use sanctuary_search_media_catalog before answering and base any named titles on the returned catalog evidence.",
152
+ validateRequiredToolResult(name, result, args) {
153
+ if (name !== "sanctuary_search_media_catalog")
154
+ return false;
155
+ const current = parseCatalogEvidence(result, args);
156
+ if (!current)
157
+ return false;
158
+ latestEvidence = current;
159
+ return true;
160
+ },
161
+ validateToolCallBeforeDispatch(name, args) {
162
+ if (name !== "sanctuary_search_media_catalog")
163
+ return undefined;
164
+ const query = (0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(args.query ?? "");
165
+ if (kind === "title_lookup" && !query)
166
+ return "Search for the requested title by name before answering whether it is on the shelf.";
167
+ if (kind === "title_lookup" && query !== requestedTitleQuery)
168
+ return `Search for the requested title ${requestedTitleQuery} before answering whether it is on the shelf.`;
169
+ if (asksForAddition && !query)
170
+ return "Search for one candidate title by name before recommending it as an addition.";
171
+ if (listCount && Number(args.limit) !== listCount)
172
+ return `Set the catalog limit to ${listCount} so the answer is grounded in exactly the requested number of titles.`;
173
+ return undefined;
174
+ },
33
175
  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.";
176
+ const commonRejection = commonAnswerRejection(answer, technicalDetailRequested);
177
+ if (commonRejection)
178
+ return commonRejection;
179
+ const latest = latestEvidence;
180
+ if (asksForCount && latest)
181
+ return catalogCountGroundingRejection(answer, latest);
182
+ if (kind === "visibility") {
183
+ if (answer.length > 240 || answer.includes("\n") || sentenceCount(answer) > 2)
184
+ return "Answer library visibility in no more than two short sentences and 240 characters.";
185
+ if (!/^yes\b/iu.test(answer.trim()))
186
+ return "Lead with a direct yes when the current catalog read succeeds.";
187
+ if (latest && !hasGroundedFirstPersonVisibility(answer, new Intl.NumberFormat("en-US").format(latest.totalItems)))
188
+ return "Answer in your own voice: say what you can see or access on the household shelf or in the library.";
189
+ if (/\b(?:titles? like|such as|including)\b/iu.test(answer))
190
+ return "Do not sample titles for a visibility question; answer whether the shelf is visible and give the current count.";
191
+ if ([...answer.matchAll(/\byes\b/giu)].length > 1)
192
+ return "Give one answer once; do not repeat the yes or restate the response.";
193
+ if (answer.includes("?"))
194
+ return "Answer the visibility question and stop without asking a new question.";
195
+ }
196
+ if (kind === "title_lookup" && latest?.query) {
197
+ const exactMatch = latest.titles.some((title) => (0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(title) === requestedTitleQuery);
198
+ const expectedLead = exactMatch ? /^yes\b/iu : /^no\b/iu;
199
+ if (!expectedLead.test(answer.trim()))
200
+ return `Lead with ${exactMatch ? "yes" : "no"} based on the current title search.`;
201
+ if (!includesNormalizedPhrase(answer, requestedTitleQuery))
202
+ return "Name the requested title in the direct answer.";
203
+ }
204
+ if (kind === "taste_recommendation" && latest) {
205
+ if (asksForAddition) {
206
+ if (!latest.query || latest.matchedItems !== 0 || !includesNormalizedPhrase(answer, latest.query))
207
+ return "Recommend an addition only after an exact current catalog search shows that candidate is absent.";
208
+ if (/\b(?:i|we)\s+(?:added|requested|submitted|queued)\b/iu.test(answer))
209
+ return "Do not claim the title was added or requested when no media-request action was available.";
210
+ }
211
+ else if (!latest.titles.some((title) => includesNormalizedPhrase(answer, title))) {
212
+ return "Make the choice from a title returned by the current catalog evidence.";
213
+ }
36
214
  }
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;
215
+ if (kind === "other" && latest)
216
+ return catalogTitleGroundingRejection(answer, latest, listCount);
217
+ return undefined;
40
218
  },
41
219
  };
42
220
  }
@@ -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,25 +172,75 @@ 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
178
  }
179
- function newOutwardCoordinates(events, existingEventIds) {
180
- return events.flatMap((event) => {
181
- if (existingEventIds.has(event.id) || event.role !== "assistant")
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;
189
+ }
190
+ function newOutwardCoordinates(events, existingEventIds, afterEventId) {
191
+ const boundaryIndex = events.findIndex((event) => event.id === afterEventId);
192
+ if (boundaryIndex < 0)
193
+ return [];
194
+ return events.flatMap((event, eventIndex) => {
195
+ if (eventIndex <= boundaryIndex || existingEventIds.has(event.id) || event.role !== "assistant" || event.provenance?.captureKind === "synthetic")
182
196
  return [];
183
- const outwardTools = event.toolCalls.filter((call) => call.function.name === "speak" || call.function.name === "settle");
197
+ const outwardTools = event.toolCalls.flatMap((call) => {
198
+ if (call.function.name !== "speak" && call.function.name !== "settle")
199
+ return [];
200
+ if (!hasAcceptedOutwardSessionAck(events, eventIndex, call.id, call.function.name))
201
+ return [];
202
+ const text = stripThinkBlocks(parseToolStringArg(call, call.function.name, call.function.name === "speak" ? "message" : "answer") ?? "");
203
+ return text ? [{ kind: call.function.name, eventId: event.id, text }] : [];
204
+ });
184
205
  if (outwardTools.length > 0)
185
- return outwardTools.map((call) => ({ kind: call.function.name, eventId: event.id }));
186
- return typeof event.content === "string" && event.content.trim() ? [{ kind: "text", eventId: event.id }] : [];
206
+ return outwardTools;
207
+ if (event.toolCalls.length > 0)
208
+ return [];
209
+ const text = stripThinkBlocks(typeof event.content === "string" ? event.content : "");
210
+ return text ? [{ kind: "text", eventId: event.id, text }] : [];
187
211
  });
188
212
  }
189
- function causalSessionEventIds(events, existingEventIds, attempts) {
190
- const coordinates = newOutwardCoordinates(events, existingEventIds);
191
- const aligned = coordinates.length === attempts.length && coordinates.every((coordinate, index) => coordinate.kind === attempts[index].kind)
192
- ? coordinates.map((coordinate) => coordinate.eventId)
193
- : attempts.map(() => null);
194
- return attempts.flatMap((attempt, index) => attempt.delivered ? [aligned[index] ?? null] : []);
213
+ function newestPlainAssistantText(messages) {
214
+ const message = messages.findLast((candidate) => candidate.role === "assistant"
215
+ && (!("tool_calls" in candidate) || !Array.isArray(candidate.tool_calls) || candidate.tool_calls.length === 0)
216
+ && typeof candidate.content === "string"
217
+ && candidate.content.trim().length > 0);
218
+ return message ? assistantContentText(message.content) : null;
219
+ }
220
+ function causalSessionEventIds(events, existingEventIds, attempts, afterEventId) {
221
+ if (!afterEventId)
222
+ return attempts.flatMap((attempt) => attempt.delivered ? [null] : []);
223
+ const coordinates = newOutwardCoordinates(events, existingEventIds, afterEventId);
224
+ let nextCoordinate = 0;
225
+ return attempts.flatMap((attempt) => {
226
+ if (!attempt.delivered)
227
+ return [];
228
+ const coordinateIndex = coordinates.findIndex((coordinate, index) => index >= nextCoordinate && coordinate.kind === attempt.kind && coordinate.text === attempt.text);
229
+ if (coordinateIndex < 0)
230
+ return [null];
231
+ nextCoordinate = coordinateIndex + 1;
232
+ return [coordinates[coordinateIndex].eventId];
233
+ });
234
+ }
235
+ function currentIngressEventId(events, existingEventIds, userMessage, precommittedIngress, ingressRelations) {
236
+ if (precommittedIngress)
237
+ return precommittedIngress.eventId;
238
+ const reference = ingressRelations?.references[0];
239
+ return events.findLast((event) => (!existingEventIds.has(event.id)
240
+ && event.role === "user"
241
+ && event.content === userMessage
242
+ && event.provenance?.captureKind !== "synthetic"
243
+ && (!reference || event.relations?.references.includes(reference))))?.id;
195
244
  }
196
245
  function getSenseSessionPath(agentName, friendId, channel, sessionKey, agentRootOverride) {
197
246
  return path.join(agentRootOverride ?? (0, identity_1.getAgentRoot)(agentName), "state", "sessions", friendId, channel, `${(0, config_1.sanitizeKey)(sessionKey)}.json`);
@@ -274,6 +323,7 @@ async function runSenseTurn(options) {
274
323
  const sessionMessages = existing?.messages && existing.messages.length > 0
275
324
  ? existing.messages
276
325
  : [{ role: "system", content: (0, prompt_1.flattenSystemPrompt)(await (0, prompt_1.buildSystem)(channel, {}, undefined)) }];
326
+ const preTurnMessageCount = sessionMessages.length;
277
327
  // Pending dir
278
328
  const pendingDir = (0, pending_1.getPendingDir)(agentName, friendId, channel, sessionKey);
279
329
  // Accumulate outward text through the same callback boundary used by chat
@@ -287,6 +337,7 @@ async function runSenseTurn(options) {
287
337
  const deliveryAttempts = [];
288
338
  let providerInvocationCount = 0;
289
339
  let toolInvocationCount = 0;
340
+ let hadReasoningChunk = false;
290
341
  const commitResponseText = (text) => {
291
342
  const cleaned = stripThinkBlocks(text);
292
343
  /* v8 ignore next -- deliverPending strips first; this is a defensive direct-call guard @preserve */
@@ -303,7 +354,7 @@ async function runSenseTurn(options) {
303
354
  if (!text)
304
355
  return;
305
356
  const delivery = { kind, text };
306
- const attempt = { kind, delivered: false };
357
+ const attempt = { kind, text, delivered: false };
307
358
  deliveryAttempts.push(attempt);
308
359
  try {
309
360
  await options.deliverySink?.onDelivery(delivery);
@@ -326,14 +377,14 @@ async function runSenseTurn(options) {
326
377
  commitResponseText(text);
327
378
  }
328
379
  };
329
- /* v8 ignore start — no-op callback stubs; only onTextChunk does real work (covered via mock) */
380
+ /* v8 ignore start — callback stubs are exercised through the pipeline integration */
330
381
  const callbacks = {
331
382
  settleOutputMode: "retractable_buffer",
332
383
  onModelStart: () => { providerInvocationCount += 1; if (options.turnMetricsObserver)
333
384
  options.turnMetricsObserver.providerInvocationCount += 1; },
334
385
  onModelStreamStart: () => { },
335
386
  onTextChunk: (chunk) => { pendingResponseText += chunk; },
336
- onReasoningChunk: () => { },
387
+ onReasoningChunk: () => { hadReasoningChunk = true; },
337
388
  onToolStart: () => { toolInvocationCount += 1; if (options.turnMetricsObserver)
338
389
  options.turnMetricsObserver.toolInvocationCount += 1; },
339
390
  onToolEnd: (name, _summary, success) => {
@@ -418,33 +469,59 @@ async function runSenseTurn(options) {
418
469
  };
419
470
  }
420
471
  const persistedEvents = persistPromise ? await persistPromise : [];
472
+ const ingressEventId = currentIngressEventId(persistedEvents, existingEventIds, userMessage, options.precommittedIngress, options.ingressRelations);
421
473
  const finalDeliveryKind = terminalDeliveryKind;
422
- if (finalDeliveryKind === "settle" && Array.isArray(turnResult.messages)) {
423
- const settledText = extractOutwardSenseDeliveryText(turnResult.messages);
424
- if (settledText)
425
- pendingResponseText = settledText;
474
+ const acceptedTerminalOutcome = turnResult.turnOutcome === "settled" || turnResult.turnOutcome === "blocked";
475
+ const failoverText = turnResult.turnOutcome === "errored" ? turnResult.failoverMessage?.trim() : undefined;
476
+ const expectsOutwardResponse = acceptedTerminalOutcome || turnResult.turnOutcome === "command" || Boolean(failoverText);
477
+ const hadPendingCallbackText = stripThinkBlocks(pendingResponseText).length > 0;
478
+ let recoveredTerminalEventId;
479
+ if (acceptedTerminalOutcome) {
480
+ const completionText = turnResult.completion?.answer.trim();
481
+ const currentTurnMessages = Array.isArray(turnResult.messages) ? turnResult.messages.slice(preTurnMessageCount) : [];
482
+ const plainTerminalText = newestPlainAssistantText(currentTurnMessages);
483
+ const acknowledgedDeliveryText = finalDeliveryKind === "settle"
484
+ ? extractOutwardSenseDeliveryText(currentTurnMessages)
485
+ : null;
486
+ const authoritativeText = completionText || acknowledgedDeliveryText || plainTerminalText;
487
+ if (authoritativeText)
488
+ pendingResponseText = authoritativeText;
489
+ else
490
+ pendingResponseText = "";
491
+ if (!hadPendingCallbackText && plainTerminalText)
492
+ recoveredTerminalEventId = causalSessionEventIds(persistedEvents, existingEventIds, [{ kind: "text", text: stripThinkBlocks(plainTerminalText), delivered: true }], ingressEventId)[0] ?? undefined;
493
+ }
494
+ else if (turnResult.turnOutcome === "command") {
495
+ // Slash-command text is emitted directly by the pipeline and has no assistant event.
496
+ }
497
+ else if (failoverText) {
498
+ pendingResponseText = failoverText;
499
+ }
500
+ else {
501
+ pendingResponseText = "";
426
502
  }
427
503
  await deliverPending(finalDeliveryKind, { throwOnError: false });
428
504
  const ponderDeferred = false;
429
505
  // Build response
430
506
  let finalResponse;
431
- let responseCausalSessionEventId;
507
+ let responseCausalSessionEventId = recoveredTerminalEventId;
432
508
  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;
509
+ if (!expectsOutwardResponse) {
510
+ finalResponse = "";
445
511
  }
446
512
  else {
447
- finalResponse = emptyFallback ?? "(agent responded but response was empty)";
513
+ // The terminal turn had no committed text — check its session transcript for the delivered answer.
514
+ const postTurnSession = (0, context_1.loadSession)(sessPath);
515
+ const emptyFallback = options.emptyResponseFallback?.();
516
+ if (postTurnSession?.messages) {
517
+ const recovered = extractOutwardSenseDeliveryText(postTurnSession.messages.slice(preTurnMessageCount));
518
+ finalResponse = recovered ?? emptyFallback ?? (hadReasoningChunk ? "" : "(agent responded but response was empty)");
519
+ if (recovered)
520
+ responseCausalSessionEventId = causalSessionEventIds(persistedEvents, existingEventIds, [{ kind: finalDeliveryKind, text: stripThinkBlocks(recovered), delivered: true }], ingressEventId)[0] ?? undefined;
521
+ }
522
+ else {
523
+ finalResponse = emptyFallback ?? (hadReasoningChunk ? "" : "(agent responded but response was empty)");
524
+ }
448
525
  }
449
526
  }
450
527
  else {
@@ -459,7 +536,7 @@ async function runSenseTurn(options) {
459
536
  // came through and nothing else, surface a clear diagnostic message
460
537
  // instead of a blank response so the operator knows what happened.
461
538
  finalResponse = stripThinkBlocks(finalResponse);
462
- if (finalResponse.length === 0) {
539
+ if (finalResponse.length === 0 && expectsOutwardResponse) {
463
540
  (0, runtime_1.emitNervesEvent)({
464
541
  level: "warn",
465
542
  component: "senses",
@@ -487,7 +564,7 @@ async function runSenseTurn(options) {
487
564
  providerInvocationCount,
488
565
  toolInvocationCount,
489
566
  sessionPath: sessPath,
490
- ...(deliveries.length > 0 ? { causalSessionEventIds: causalSessionEventIds(persistedEvents, existingEventIds, deliveryAttempts) } : {}),
567
+ ...(deliveries.length > 0 ? { causalSessionEventIds: causalSessionEventIds(persistedEvents, existingEventIds, deliveryAttempts, ingressEventId) } : {}),
491
568
  ...(responseCausalSessionEventId ? { responseCausalSessionEventId } : {}),
492
569
  };
493
570
  });
@@ -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.794",
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.794",
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.794",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },