@ouro.bot/cli 0.1.0-alpha.942 → 0.1.0-alpha.944
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 +12 -0
- package/deploy/unraid/sanctuary-replay-gate.mjs +22 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/external-events/router.js +7 -0
- package/dist/repertoire/tools-continuity.js +10 -3
- package/dist/senses/private-runtime.js +2 -2
- package/dist/senses/sanctuary-media-catalog-contract.js +13 -4
- package/dist/senses/telegram-effect-adapter.js +4 -0
- package/dist/senses/telegram.js +40 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
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.944",
|
|
6
|
+
"changes": [
|
|
7
|
+
"The Butler sends at most one Telegram warning for a batch of health events from one sweep, records the rest as covered by it, and settles a retry of an already-sent warning without sending it again."
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"version": "0.1.0-alpha.943",
|
|
12
|
+
"changes": [
|
|
13
|
+
"Butler: a question like \"do you have any idea when that movie will be available?\" no longer gets treated as a title lookup that blocks every search until the step limit; the replay gate now checks this case."
|
|
14
|
+
]
|
|
15
|
+
},
|
|
4
16
|
{
|
|
5
17
|
"version": "0.1.0-alpha.942",
|
|
6
18
|
"changes": [
|
|
@@ -325,6 +325,23 @@ export const CASES = [
|
|
|
325
325
|
]
|
|
326
326
|
},
|
|
327
327
|
},
|
|
328
|
+
{
|
|
329
|
+
// 2026-10-24: "do you have any idea when that Elizabeth Holmes documentary that we have requested will be available?"
|
|
330
|
+
// was read as a shelf lookup for the whole sentence; every search was refused and the turn ended at the step limit.
|
|
331
|
+
id: "request-status",
|
|
332
|
+
words: "do you have any idea when that Hope movie we requested will be available?",
|
|
333
|
+
sender: "principal",
|
|
334
|
+
delegated: false,
|
|
335
|
+
readback: ({ trace, reply, said }) => {
|
|
336
|
+
const lookups = callsNamed(trace, /^media_/).filter((call) => String(call.result ?? "").trim())
|
|
337
|
+
const text = said || reply
|
|
338
|
+
return [
|
|
339
|
+
check("a media tool ran and returned a result", lookups.length > 0, `${trace.length} tool calls`),
|
|
340
|
+
check("the reply answers instead of reporting the step limit", Boolean(text.trim()) && !/step limit/i.test(text)),
|
|
341
|
+
check("no new request was filed", callsNamed(trace, /^media_request$/).length === 0),
|
|
342
|
+
]
|
|
343
|
+
},
|
|
344
|
+
},
|
|
328
345
|
{
|
|
329
346
|
id: "await-self-resolve",
|
|
330
347
|
// A name unique to the run: an earlier run leaves an archived await of the same name behind, and the Butler's
|
|
@@ -741,6 +758,11 @@ export function selfTestFixtures() {
|
|
|
741
758
|
pass: { trace: [call("1", "media_queue", {})], reply: "it is stuck", before: emptyObservation({ queue: [stalled] }), after: emptyObservation({ queue: [stalled] }) },
|
|
742
759
|
fail: { trace: [call("1", "media_blocklist_stalled", {})], reply: "removed it", before: emptyObservation({ queue: [stalled] }), after: emptyObservation({ queue: [] }) },
|
|
743
760
|
},
|
|
761
|
+
"request-status": {
|
|
762
|
+
pass: { trace: [call("1", "media_request_status", { query: "Hope" }, '{"status":"downloading"}')], reply: "It's downloading now." },
|
|
763
|
+
failStepLimit: { trace: [], reply: "I hit my step limit in the middle of that job and couldn't finish it." },
|
|
764
|
+
failNewRequest: { trace: [call("1", "media_search", { query: "Hope" }, '{"matched":1}'), call("2", "media_request", { query: "Hope" }, '{"ok":true}')], reply: "Requested it." },
|
|
765
|
+
},
|
|
744
766
|
"failure-report": {
|
|
745
767
|
pass: { trace: [call("1", "report_failure", { ari_words: "set the thermostat" }, '{"filed":true,"reportId":"r1"}')], reply: "I've filed this for a fix", before: emptyObservation(), after: emptyObservation({ outbox: { e: ["a"] }, outboxBodies: { e: "Ari asked: please set the living room thermostat" } }) },
|
|
746
768
|
fail: { trace: [call("1", "report_failure", {}, '{"filed":true}')], reply: "filed", before: emptyObservation(), after: emptyObservation({ outbox: { e: ["a"], s: ["b"] }, outboxBodies: { e: "something else" } }) },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.944</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -1313,6 +1313,13 @@ function validateDisposition(disposition) {
|
|
|
1313
1313
|
assertBoundedText(ref, "disposition reference", 512);
|
|
1314
1314
|
if (disposition.nextWake.kind === "at")
|
|
1315
1315
|
assertBoundedText(disposition.nextWake.at, "wake time", 64);
|
|
1316
|
+
if (disposition.ownerMessage) {
|
|
1317
|
+
if (!["sent", "already_sent", "covered"].includes(disposition.ownerMessage.status))
|
|
1318
|
+
throw new Error("External event owner message status is invalid");
|
|
1319
|
+
if (!disposition.ownerMessage.effectKey)
|
|
1320
|
+
throw new Error("External event owner message key is missing");
|
|
1321
|
+
assertBoundedText(disposition.ownerMessage.effectKey, "owner message key", 1_024);
|
|
1322
|
+
}
|
|
1316
1323
|
}
|
|
1317
1324
|
function commitExternalEventDisposition(recordPath, input) {
|
|
1318
1325
|
return withRecordLock(recordPath, () => {
|
|
@@ -176,12 +176,18 @@ function prepareExternalEventDisposition(a, ctx) {
|
|
|
176
176
|
return {
|
|
177
177
|
recordPath,
|
|
178
178
|
finish: async () => {
|
|
179
|
+
let ownerMessage;
|
|
179
180
|
if (decision === "ask" || decision === "report") {
|
|
180
181
|
if (Buffer.byteLength(reason, "utf8") > 1_200)
|
|
181
182
|
throw new Error("External event owner message must be phone-sized");
|
|
182
183
|
if (!ctx?.externalEventEffects)
|
|
183
184
|
throw new Error("External event owner delivery is unavailable");
|
|
184
|
-
|
|
185
|
+
// The whole dispatched batch shares one owner message; the delivery port settles later decisions as covered by it.
|
|
186
|
+
const relatedEvents = [turnContext, ...(turnContext.relatedEvents ?? [])]
|
|
187
|
+
.filter((event) => path.resolve(event.recordPath) !== recordPath)
|
|
188
|
+
.map((event) => ({ source: event.source, eventId: event.eventId, generation: event.generation }));
|
|
189
|
+
const delivered = await ctx.externalEventEffects.deliverOwnerDecision({ source: record.source, eventId: record.eventId, generation: record.generation, text: reason, relatedEvents });
|
|
190
|
+
ownerMessage = { status: delivered.status, effectKey: delivered.effectKey };
|
|
185
191
|
}
|
|
186
192
|
const handled = (0, router_1.commitExternalEventDisposition)(recordPath, {
|
|
187
193
|
owner: turnEvent.claimOwner,
|
|
@@ -198,10 +204,11 @@ function prepareExternalEventDisposition(a, ctx) {
|
|
|
198
204
|
awaitId: typeof a.awaitId === "string" && a.awaitId ? a.awaitId : null,
|
|
199
205
|
actionRefs,
|
|
200
206
|
verificationRefs,
|
|
207
|
+
...(ownerMessage ? { ownerMessage } : {}),
|
|
201
208
|
},
|
|
202
209
|
});
|
|
203
210
|
ctx?.externalEventAuthority?.recordCommittedDisposition?.(turnEvent);
|
|
204
|
-
(0, runtime_1.emitNervesEvent)({ component: "repertoire", event: "repertoire.external_event_disposition", message: "external event disposition recorded", meta: { agentName, eventId: record.eventId, generation: record.generation, classification, decision } });
|
|
211
|
+
(0, runtime_1.emitNervesEvent)({ component: "repertoire", event: "repertoire.external_event_disposition", message: "external event disposition recorded", meta: { agentName, eventId: record.eventId, generation: record.generation, classification, decision, ...(ownerMessage ? { ownerMessage: ownerMessage.status } : {}) } });
|
|
205
212
|
return handled;
|
|
206
213
|
},
|
|
207
214
|
};
|
|
@@ -213,7 +220,7 @@ exports.continuityToolDefinitions = [
|
|
|
213
220
|
type: "function",
|
|
214
221
|
function: {
|
|
215
222
|
name: "external_event_disposition",
|
|
216
|
-
description: "Classify the exact external-event generation I just investigated. An ask or report is the sole owner-delivery path and sends reason once for this receipt; silent and act only record the disposition.",
|
|
223
|
+
description: "Classify the exact external-event generation I just investigated. An ask or report is the sole owner-delivery path and sends reason once for this receipt; silent and act only record the disposition. In a batch, only the first ask or report sends a message and the later ones are recorded as covered by it, so write that first reason to cover every listed lease.",
|
|
217
224
|
parameters: {
|
|
218
225
|
type: "object",
|
|
219
226
|
properties: {
|
|
@@ -893,7 +893,7 @@ function buildExternalEventLeaseMessage(event) {
|
|
|
893
893
|
const members = [event, ...(event.relatedEvents ?? [])];
|
|
894
894
|
const dispositionInstruction = members.length === 1
|
|
895
895
|
? "Investigate the current observation, then call external_event_disposition once for this lease before settling."
|
|
896
|
-
: "Investigate the current observations, then call external_event_disposition once with one bounded batch containing every listed lease before settling.";
|
|
896
|
+
: "Investigate the current observations, then call external_event_disposition once with one bounded batch containing every listed lease before settling. The batch gets at most one owner message: the first ask or report sends it, so its reason must cover every lease.";
|
|
897
897
|
return [
|
|
898
898
|
"[current external-event disposition contract]",
|
|
899
899
|
"This exact lease frame is authoritative for this turn. Ignore older receipts, failed tool arguments, held-work summaries, and checkpoints when choosing disposition arguments.",
|
|
@@ -1368,7 +1368,7 @@ async function runPrivateRuntimeTurn(options) {
|
|
|
1368
1368
|
externalEventEffects: {
|
|
1369
1369
|
deliverOwnerDecision: async (input) => {
|
|
1370
1370
|
const { sendTelegramExternalEventDecision } = await Promise.resolve().then(() => __importStar(require("./telegram")));
|
|
1371
|
-
|
|
1371
|
+
return sendTelegramExternalEventDecision(agentName, input);
|
|
1372
1372
|
},
|
|
1373
1373
|
},
|
|
1374
1374
|
};
|
|
@@ -26,11 +26,13 @@ function requestedListCount(request) {
|
|
|
26
26
|
const words = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10 };
|
|
27
27
|
return words[match[1]] ?? Number(match[1]);
|
|
28
28
|
}
|
|
29
|
+
// "do you have any idea when…" asks about something, it does not name a title (2026-10-24 Telegram step-limit loop).
|
|
30
|
+
const NOT_A_TITLE = /^(?:any|an|a|no|some)\s+(?:idea|clue|eta|update|updates|news|word|sense|guess|info|information|time|way|chance)\b/u;
|
|
29
31
|
function requestedTitle(request) {
|
|
30
32
|
const conversationalSuffixRemoved = request.replace(/\s+(?:please|(?:right\s+)?now|currently)$/u, "");
|
|
31
33
|
const haveMatch = conversationalSuffixRemoved.match(/\b(?:do|did|can|could)\s+(?:we|you)\s+(?:have|got|stock)\s+(.+)$/u);
|
|
32
34
|
if (haveMatch)
|
|
33
|
-
return haveMatch[1].replace(/^(?:the\s+)?(?:movie|film|show)\s+/u, "").trim();
|
|
35
|
+
return NOT_A_TITLE.test(haveMatch[1]) ? "" : haveMatch[1].replace(/^(?:the\s+)?(?:movie|film|show)\s+/u, "").trim();
|
|
34
36
|
const locationMatch = conversationalSuffixRemoved.match(/\b(?:is|are)\s+(.+?)\s+(?:in|on)\s+(?:(?:the|our)\s+)?(?:jellyfin|library|lib|catalog|shelf)$/u);
|
|
35
37
|
return locationMatch?.[1]?.replace(/^(?:the\s+)?(?:movie|film|show)\s+/u, "").trim() ?? "";
|
|
36
38
|
}
|
|
@@ -122,6 +124,12 @@ function parseMediaSearchEvidence(result, args) {
|
|
|
122
124
|
return null;
|
|
123
125
|
}
|
|
124
126
|
}
|
|
127
|
+
/** A search names the requested title when it is that title or a complete, non-glue phrase inside it ("Moonstruck" for "moonstruck on blu ray"). */
|
|
128
|
+
function searchNamesRequestedTitle(query, requested) {
|
|
129
|
+
if (query === requested)
|
|
130
|
+
return true;
|
|
131
|
+
return includesNormalizedPhrase(requested, query) && query.split(" ").some((word) => !TITLE_LIST_GLUE.has(word));
|
|
132
|
+
}
|
|
125
133
|
function sentenceCount(answer) {
|
|
126
134
|
return answer.split(/[.!?]+(?:\s+|$)/u).filter((part) => part.trim()).length;
|
|
127
135
|
}
|
|
@@ -208,7 +216,7 @@ function sanctuaryMediaCatalogRequiredToolCalls(request, advertisedToolNames) {
|
|
|
208
216
|
const query = (0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(args.query ?? "");
|
|
209
217
|
if (kind === "title_lookup" && !query)
|
|
210
218
|
return "Search for the requested title by name before answering whether it is on the shelf.";
|
|
211
|
-
if (kind === "title_lookup" && query
|
|
219
|
+
if (kind === "title_lookup" && !searchNamesRequestedTitle(query, requestedTitleQuery))
|
|
212
220
|
return `Search for the requested title ${requestedTitleQuery} before answering whether it is on the shelf.`;
|
|
213
221
|
if (listCount && Number(args.limit) !== listCount && catalogTool !== MEDIA_SEARCH_TOOL)
|
|
214
222
|
return `Set the catalog limit to ${listCount} so the answer is grounded in exactly the requested number of titles.`;
|
|
@@ -239,11 +247,12 @@ function sanctuaryMediaCatalogRequiredToolCalls(request, advertisedToolNames) {
|
|
|
239
247
|
return "Answer the visibility question and stop without asking a new question.";
|
|
240
248
|
}
|
|
241
249
|
if (kind === "title_lookup" && latest?.query) {
|
|
242
|
-
|
|
250
|
+
// The dispatch guard admitted latest.query as the requested title (or the title inside it), so ground the answer in that search.
|
|
251
|
+
const exactMatch = latest.titles.some((title) => (0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(title) === latest.query);
|
|
243
252
|
const expectedLead = exactMatch ? /^yes\b/iu : /^no\b/iu;
|
|
244
253
|
if (!expectedLead.test(answer.trim()))
|
|
245
254
|
return `Lead with ${exactMatch ? "yes" : "no"} based on the current title search.`;
|
|
246
|
-
if (!includesNormalizedPhrase(answer,
|
|
255
|
+
if (!includesNormalizedPhrase(answer, latest.query))
|
|
247
256
|
return "Name the requested title in the direct answer.";
|
|
248
257
|
}
|
|
249
258
|
if (kind === "other" && latest) {
|
|
@@ -284,6 +284,10 @@ class FileTelegramEffectJournal {
|
|
|
284
284
|
const filePath = this.artifactPath(id);
|
|
285
285
|
return fs.existsSync(filePath) ? this.read(id) : null;
|
|
286
286
|
}
|
|
287
|
+
/** The artifact an idempotency key already owns, if any; its existence means the journal owns that delivery. */
|
|
288
|
+
readByIdempotencyKey(idempotencyKey) {
|
|
289
|
+
return this.readIfExists(artifactId(idempotencyKey));
|
|
290
|
+
}
|
|
287
291
|
quarantineInvalid(id, reason) {
|
|
288
292
|
this.assertRootIdentity();
|
|
289
293
|
const filePath = this.artifactPath(id);
|
package/dist/senses/telegram.js
CHANGED
|
@@ -1954,19 +1954,49 @@ function createTelegramSenseApp(options) {
|
|
|
1954
1954
|
});
|
|
1955
1955
|
},
|
|
1956
1956
|
async sendExternalEventDecision(input) {
|
|
1957
|
-
const
|
|
1958
|
-
|
|
1959
|
-
|
|
1957
|
+
const identities = [input, ...(input.relatedEvents ?? [])].map((event) => ({
|
|
1958
|
+
source: requiredText(event.source, "external event source"),
|
|
1959
|
+
eventId: requiredText(event.eventId, "external event id"),
|
|
1960
|
+
generation: event.generation,
|
|
1961
|
+
}));
|
|
1962
|
+
if (identities.some(({ source, eventId, generation }) => Buffer.byteLength(source) > 512 || Buffer.byteLength(eventId) > 512
|
|
1963
|
+
|| !Number.isSafeInteger(generation) || generation < 1))
|
|
1960
1964
|
throw new Error("Telegram external event identity is invalid");
|
|
1961
|
-
|
|
1962
|
-
|
|
1965
|
+
const [key, ...relatedKeys] = identities.map(({ source, eventId, generation }) => `external-event:${JSON.stringify([source, eventId, generation])}`);
|
|
1966
|
+
return runWithAcceptanceAuditOwner(async () => {
|
|
1963
1967
|
const text = requiredText(input.text, "external event decision");
|
|
1964
|
-
if (holdNoticeDuringHandoff(text, `external-event:${receiptIdentity}`))
|
|
1965
|
-
return;
|
|
1966
|
-
await releaseHeldNotices(input.signal);
|
|
1967
|
-
const effect = await deliverButlerEffect(text, `external-event:${receiptIdentity}`, input.signal);
|
|
1968
1968
|
const sessionPath = (0, shared_turn_1.getSenseSessionPath)(options.agentName, configuredOwnerFriendId, "telegram", configuredOwnerSessionKey, agentRoot);
|
|
1969
|
+
const held = (0, telegram_handoff_1.readHeldNotices)(heldNoticesPath);
|
|
1970
|
+
const ownerMessageExists = (candidate) => held.some((notice) => notice.key === candidate)
|
|
1971
|
+
|| getEffectJournal().readByIdempotencyKey(candidate) !== null;
|
|
1972
|
+
if (ownerMessageExists(key)) {
|
|
1973
|
+
// A retry after the message went out (or was held): finish the original send, never send a reworded second one.
|
|
1974
|
+
const existing = getEffectJournal().readByIdempotencyKey(key);
|
|
1975
|
+
if (existing) {
|
|
1976
|
+
await releaseHeldNotices(input.signal);
|
|
1977
|
+
await recordAcceptedEffects(sessionPath, [await executeAuthorizedEffect({
|
|
1978
|
+
idempotencyKey: key, target: existing.target, authorClass: existing.authorClass, effect: existing.effect,
|
|
1979
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
1980
|
+
})]);
|
|
1981
|
+
}
|
|
1982
|
+
return { status: "already_sent", effectKey: key };
|
|
1983
|
+
}
|
|
1984
|
+
const coveredBy = relatedKeys.find(ownerMessageExists);
|
|
1985
|
+
if (coveredBy) {
|
|
1986
|
+
(0, runtime_1.emitNervesEvent)({
|
|
1987
|
+
component: "senses",
|
|
1988
|
+
event: "senses.telegram_external_event_decision_covered",
|
|
1989
|
+
message: "settled an external-event decision under the owner message already sent for its batch",
|
|
1990
|
+
meta: { agentName: options.agentName, effectKey: key, coveredBy },
|
|
1991
|
+
});
|
|
1992
|
+
return { status: "covered", effectKey: coveredBy };
|
|
1993
|
+
}
|
|
1994
|
+
if (holdNoticeDuringHandoff(text, key))
|
|
1995
|
+
return { status: "sent", effectKey: key };
|
|
1996
|
+
await releaseHeldNotices(input.signal);
|
|
1997
|
+
const effect = await deliverButlerEffect(text, key, input.signal);
|
|
1969
1998
|
await recordAcceptedEffects(sessionPath, [effect]);
|
|
1999
|
+
return { status: "sent", effectKey: key };
|
|
1970
2000
|
});
|
|
1971
2001
|
},
|
|
1972
2002
|
async sendAwaitFollowUp(request) {
|
|
@@ -2315,7 +2345,7 @@ async function startTelegramSenseApp(agentName, waitForAuthority = false) {
|
|
|
2315
2345
|
async function sendTelegramExternalEventDecision(agentName, input) {
|
|
2316
2346
|
const app = await startTelegramSenseApp(agentName);
|
|
2317
2347
|
try {
|
|
2318
|
-
await app.sendExternalEventDecision(input);
|
|
2348
|
+
return await app.sendExternalEventDecision(input);
|
|
2319
2349
|
}
|
|
2320
2350
|
finally {
|
|
2321
2351
|
await app.stop();
|
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.944",
|
|
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.944",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|