@ouro.bot/cli 0.1.0-alpha.943 → 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 CHANGED
@@ -1,6 +1,12 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.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
+ },
4
10
  {
5
11
  "version": "0.1.0-alpha.943",
6
12
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.943",
2
+ "runtimeVersion": "0.1.0-alpha.944",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -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.943</Repository>
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
- await ctx.externalEventEffects.deliverOwnerDecision({ source: record.source, eventId: record.eventId, generation: record.generation, text: reason });
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
- await sendTelegramExternalEventDecision(agentName, input);
1371
+ return sendTelegramExternalEventDecision(agentName, input);
1372
1372
  },
1373
1373
  },
1374
1374
  };
@@ -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);
@@ -1954,19 +1954,49 @@ function createTelegramSenseApp(options) {
1954
1954
  });
1955
1955
  },
1956
1956
  async sendExternalEventDecision(input) {
1957
- const source = requiredText(input.source, "external event source");
1958
- const eventId = requiredText(input.eventId, "external event id");
1959
- if (Buffer.byteLength(source) > 512 || Buffer.byteLength(eventId) > 512 || !Number.isSafeInteger(input.generation) || input.generation < 1)
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
- await runWithAcceptanceAuditOwner(async () => {
1962
- const receiptIdentity = JSON.stringify([source, eventId, input.generation]);
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();
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.943",
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.943",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.943",
3
+ "version": "0.1.0-alpha.944",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },