@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.
- package/changelog.json +34 -20
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +1 -1
- package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/core.js +1065 -1050
- package/dist/senses/sanctuary-media-catalog-contract.js +196 -18
- package/dist/senses/sanctuary-media-optimization.js +17 -3
- package/dist/senses/shared-turn.js +118 -41
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 =
|
|
18
|
-
const mentionsMedia = /\b(?:film|films|movie|movies|
|
|
19
|
-
const asksCatalog = /\b(?:have|got|stock|catalog|library|lib|favorite|favourite|recommend|suggest|pick|watch|see)\b/u.test(normalized);
|
|
20
|
-
const
|
|
21
|
-
if ((!mentionsMedia && !
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
467
|
-
if (Buffer.byteLength(
|
|
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
|
|
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
|
-
|
|
136
|
-
return null;
|
|
135
|
+
const toolCalls = assistant.tool_calls;
|
|
137
136
|
const delivered = [];
|
|
138
|
-
for (let index = 0; index <
|
|
139
|
-
const toolCall =
|
|
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
|
|
177
|
-
|
|
175
|
+
return Array.isArray(assistant.tool_calls) && assistant.tool_calls.length > 0
|
|
176
|
+
? outwardDeliveryTextFromAssistantTools(messages, assistantIndex)
|
|
177
|
+
: assistantContentText(assistant.content);
|
|
178
178
|
}
|
|
179
|
-
function
|
|
180
|
-
|
|
181
|
-
|
|
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.
|
|
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
|
|
186
|
-
|
|
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
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
return
|
|
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 —
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
});
|
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.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.
|
|
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",
|