@ouro.bot/cli 0.1.0-alpha.956 → 0.1.0-alpha.957

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,22 @@
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.957",
6
+ "changes": [
7
+ "Butler no longer starts a model turn just to close a problem that fixed itself, unless he had told you about it.",
8
+ "Background turns that run out of steps now wrap up cleanly instead of failing.",
9
+ "Turns started by server events now count against Butler's daily allowance, and repeated failures pause them for 30 minutes.",
10
+ "Media lookups now say whether a title was actually requested and whether it is downloading, instead of claiming it is being processed.",
11
+ "Questions and reports Butler sends you are written in plain words, separate from his internal notes.",
12
+ "A web endpoint must fail two health checks in a row before Butler treats it as down, and informational Unraid notices are ignored.",
13
+ "The house digest stops repeating the same stuck items as new.",
14
+ "Butler sends at most three unprompted messages a day and none between 23:00 and 08:00, except disk, parity and array alerts.",
15
+ "Server events blocked by Butler's daily allowance or failure pause now wait and retry instead of being dropped, and disk, parity and array events are never held back.",
16
+ "The health acceptance probe keeps checking a web endpoint once per sweep, so its scripted scenarios still match what Butler does.",
17
+ "Media request and request-status answers use the same plain shelf states (downloading, in Radarr or Sonarr but not downloading, available) instead of \"processing\"."
18
+ ]
19
+ },
4
20
  {
5
21
  "version": "0.1.0-alpha.956",
6
22
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.956",
2
+ "runtimeVersion": "0.1.0-alpha.957",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -94,7 +94,7 @@ const prowlarr = (path, opts = {}) => req(PROWLARR.url, `/api/v1${path}`, { key:
94
94
  // ------------------------------------------------------------------ mapping
95
95
 
96
96
  // Jellyseerr media status codes.
97
- const MEDIA_STATUS = { 1: "unknown", 2: "pending", 3: "processing", 4: "partially_available", 5: "available" }
97
+ const MEDIA_STATUS = { 1: "unknown", 2: "pending", 4: "partially_available", 5: "available" }
98
98
  const REQUEST_STATUS = { 1: "pending_approval", 2: "approved", 3: "declined", 4: "failed" }
99
99
 
100
100
  const TMDB_GENRES = {
@@ -110,11 +110,25 @@ function genreNames(item) {
110
110
  return ids.map((id) => TMDB_GENRES[id]).filter(Boolean)
111
111
  }
112
112
 
113
- function shelfItem(item) {
113
+ // Jellyseerr's media status 3 only means Sonarr/Radarr track the title; it says nothing about
114
+ // a download or a request. Name what is actually known so nobody reads it as "processing".
115
+ function shelfStateOf(kind, info) {
116
+ if (info.status === 3) return info.downloadStatus?.length ? "downloading" : `in_${kind === "series" ? "sonarr" : "radarr"}_not_downloading`
117
+ return MEDIA_STATUS[info.status] ?? "not_on_shelf"
118
+ }
119
+
120
+ // A request exists only if Jellyseerr lists one; the media status is not a request.
121
+ function requestStateOf(info) {
122
+ if (!Array.isArray(info.requests)) return info.status ? "unknown_check_media_request_status" : "never_requested"
123
+ if (info.requests.length === 0) return "never_requested"
124
+ return REQUEST_STATUS[info.requests[info.requests.length - 1].status] ?? "requested"
125
+ }
126
+
127
+ export function shelfItem(item) {
114
128
  const kind = item.mediaType === "tv" ? "series" : item.mediaType === "movie" ? "movie" : (item.firstAirDate ? "series" : "movie")
115
129
  const date = item.releaseDate ?? item.firstAirDate ?? ""
116
130
  const info = item.mediaInfo ?? {}
117
- const status = MEDIA_STATUS[info.status] ?? "not_on_shelf"
131
+ const status = shelfStateOf(kind, info)
118
132
  return {
119
133
  title: item.title ?? item.name ?? null,
120
134
  year: date ? Number(date.slice(0, 4)) : null,
@@ -126,8 +140,8 @@ function shelfItem(item) {
126
140
  // The two fields that stop a second call:
127
141
  on_shelf: status === "available" || status === "partially_available",
128
142
  shelf_state: status,
129
- request_state: info.status ? (status === "available" ? "available" : status) : "never_requested",
130
- sonarr_id: info.externalServiceId ?? null,
143
+ request_state: requestStateOf(info),
144
+ sonarr_id: kind === "series" ? (info.externalServiceId ?? null) : null,
131
145
  radarr_id: kind === "movie" ? (info.externalServiceId ?? null) : null,
132
146
  poster_path: item.posterPath ? `https://image.tmdb.org/t/p/w500${item.posterPath}` : null,
133
147
  vote_average: item.voteAverage ?? null,
@@ -385,7 +399,7 @@ async function mediaRequest(a) {
385
399
 
386
400
  const existing = await findExistingRequest(tmdbId, kind)
387
401
  if (existing?.hasRequest) {
388
- const shelf = MEDIA_STATUS[existing.media?.status] ?? "unknown"
402
+ const shelf = shelfStateOf(kind, existing.media ?? {})
389
403
  return {
390
404
  resolved: true, title: resolvedTitle ?? existing.media?.title ?? null, year: resolvedYear, kind, tmdb_id: tmdbId,
391
405
  request_id: `jellyseerr:${existing.id}`,
@@ -412,7 +426,7 @@ async function mediaRequest(a) {
412
426
  approval_state: REQUEST_STATUS[created.status] ?? "submitted",
413
427
  duplicate_of: null,
414
428
  jellyseerr_status: REQUEST_STATUS[created.status] ?? "submitted",
415
- shelf_state: MEDIA_STATUS[created.media?.status] ?? "processing",
429
+ shelf_state: created.media?.status ? shelfStateOf(kind, created.media) : "not_on_shelf",
416
430
  submitted_at: created.createdAt,
417
431
  tvdb_id: created.media?.tvdbId ?? null,
418
432
  sonarr_id: kind === "series" ? (created.media?.externalServiceId ?? null) : null,
@@ -520,7 +534,7 @@ export async function mediaRequestStatus(a) {
520
534
 
521
535
  const kind = request.type === "tv" ? "series" : "movie"
522
536
  const media = request.media ?? {}
523
- const shelf = MEDIA_STATUS[media.status] ?? "unknown"
537
+ const shelf = shelfStateOf(kind, media)
524
538
  const svcId = media.externalServiceId ?? null
525
539
 
526
540
  const result = {
@@ -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.956</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.957</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -285,6 +285,7 @@ exports.STEP_BUDGET_EXTENSION = 4;
285
285
  /** Hard ceiling on the per-turn provider-call budget, however much progress is made. */
286
286
  exports.MAX_PROVIDER_ITERATIONS_CEILING = 24;
287
287
  const STEP_BUDGET_REPORT_INSTRUCTION = "the step budget for this turn is used up. do not call any more work tools; none of the tool calls in your last message were executed. call settle now with a short progress report: what is done (with evidence from tool results above), what is still in flight, and what you will do next. say you can continue where you left off when the conversation resumes.";
288
+ const STEP_BUDGET_REST_INSTRUCTION = "the step budget for this turn is used up. do not call any more work tools; none of the tool calls in your last message were executed. call rest now with a short note: what is done, what is still open, and what to pick up next time.";
288
289
  function continuationCallbacks(callbacks, text) {
289
290
  const noop = () => undefined;
290
291
  return {
@@ -1208,6 +1209,10 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1208
1209
  let stepBudget = exports.MAX_PROVIDER_ITERATIONS;
1209
1210
  let lastIterationMadeProgress = false;
1210
1211
  let stepReportIteration = null;
1212
+ // The one tool a turn may call after its step budget is used up: settle for conversations, rest for private turns.
1213
+ let stepReportTool = "settle";
1214
+ // A pre-dispatch contract check may reject the same call twice in a turn; after that it is advisory, so a check no call can satisfy never traps the turn.
1215
+ const requiredDispatchRejectionCounts = new Map();
1211
1216
  const budgetExhausted = () => {
1212
1217
  if (providerIterations < stepBudget)
1213
1218
  return false;
@@ -1444,7 +1449,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1444
1449
  .filter((name) => candidateToolNames.has(name)));
1445
1450
  const forcingHistoricalEffect = forcedHistoricalToolNames.size > 0;
1446
1451
  const activeTools = stepReportIteration !== null
1447
- ? candidateActiveTools.filter((tool) => tool.function.name === "settle")
1452
+ ? candidateActiveTools.filter((tool) => tool.function.name === stepReportTool)
1448
1453
  : forcingHistoricalEffect
1449
1454
  ? candidateActiveTools.filter((tool) => forcedHistoricalToolNames.has(tool.function.name))
1450
1455
  : candidateActiveTools;
@@ -1605,8 +1610,8 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1605
1610
  nextAttemptControls = [];
1606
1611
  providerIterations += 1;
1607
1612
  if (stepReportIteration !== null) {
1608
- // The progress-report call is the last one: it must settle, or the turn errors.
1609
- const settledReport = result.toolCalls.length === 1 && result.toolCalls[0].name === "settle";
1613
+ // The progress-report call is the last one: it must settle (or rest), or the turn errors.
1614
+ const settledReport = result.toolCalls.length === 1 && result.toolCalls[0].name === stepReportTool;
1610
1615
  if (providerIterations > stepReportIteration || (result.toolCalls.length > 0 && !settledReport)) {
1611
1616
  throw new Error(`provider iteration limit exhausted at response ${providerIterations} before the progress report settled`);
1612
1617
  }
@@ -1676,20 +1681,22 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1676
1681
  if (thinkingItems.length > 0) {
1677
1682
  msg._thinking_blocks = thinkingItems;
1678
1683
  }
1679
- const settlingNow = result.toolCalls.length === 1 && result.toolCalls[0].name === "settle";
1684
+ const settlingNow = result.toolCalls.length === 1 && (result.toolCalls[0].name === "settle" || result.toolCalls[0].name === "rest");
1680
1685
  if (stepReportIteration === null && result.toolCalls.length > 0 && !settlingNow && budgetExhausted()) {
1681
- if (!candidateToolNames.has("settle")) {
1686
+ const wrapUpTool = candidateToolNames.has("settle") ? "settle" : candidateToolNames.has("rest") ? "rest" : null;
1687
+ if (!wrapUpTool) {
1682
1688
  throw new Error(`provider iteration limit exhausted at response ${stepBudget} before tool execution`);
1683
1689
  }
1684
- // Do not error into the reply: give the model one settle-only call to report progress.
1690
+ // Do not error into the reply: give the model one settle-only (or rest-only) call to report progress.
1685
1691
  streamCallbackBuffer?.discard();
1686
1692
  stepReportIteration = providerIterations + 1;
1693
+ stepReportTool = wrapUpTool;
1687
1694
  rejectAttempt(msg, [
1688
1695
  ...result.toolCalls.map((tc) => ({ role: "tool", tool_call_id: tc.id, content: "not executed: the step budget for this turn is used up." })),
1689
- { role: "user", content: STEP_BUDGET_REPORT_INSTRUCTION },
1696
+ { role: "user", content: wrapUpTool === "settle" ? STEP_BUDGET_REPORT_INSTRUCTION : STEP_BUDGET_REST_INSTRUCTION },
1690
1697
  ]);
1691
1698
  callbacks.onClearText?.();
1692
- (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.step_budget_exhausted", message: "step budget used up; requesting a settle-only progress report", meta: { stepBudget, providerIterations } });
1699
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.step_budget_exhausted", message: `step budget used up; requesting a ${wrapUpTool}-only progress report`, meta: { stepBudget, providerIterations } });
1693
1700
  continue;
1694
1701
  }
1695
1702
  if (result.toolCalls.length > 0)
@@ -2166,14 +2173,16 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2166
2173
  const restArgs = validatedCallArguments.get(result.toolCalls[0]);
2167
2174
  callbacks.onToolStart("rest", restArgs);
2168
2175
  // Attention queue gate: reject rest if items remain
2176
+ // A budget wrap-up rest is final: held work and fresh messages wait for the next turn.
2177
+ const wrappingUp = stepReportIteration !== null;
2169
2178
  const attentionQueue = augmentedToolContext?.delegatedOrigins;
2170
- if (attentionQueue && attentionQueue.length > 0) {
2179
+ if (!wrappingUp && attentionQueue && attentionQueue.length > 0) {
2171
2180
  callbacks.onToolEnd("rest", (0, tools_1.summarizeArgs)("rest", restArgs, toolSelection), false);
2172
2181
  const gateMessage = "current held-work frame still has unsurfaced items — return each listed item with surface(delegationId=...) before you rest. Older transcript claims are historical; only the current held-work frame is the gate.";
2173
2182
  rejectToolBatch(msg, [gateMessage]);
2174
2183
  continue;
2175
2184
  }
2176
- if (hasFreshPendingWork(options) && !freshWorkGateFired) {
2185
+ if (!wrappingUp && hasFreshPendingWork(options) && !freshWorkGateFired) {
2177
2186
  freshWorkGateFired = true;
2178
2187
  callbacks.onToolEnd("rest", (0, tools_1.summarizeArgs)("rest", restArgs, toolSelection), false);
2179
2188
  const gateMessage = "fresh work arrived for me this turn — inspect the pending messages above and take the next concrete action before you rest.";
@@ -2206,8 +2215,20 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2206
2215
  for (const entry of validCalls) {
2207
2216
  const requiredArgs = entry.validated.arguments;
2208
2217
  const rejection = options?.requiredToolCalls?.validateToolCallBeforeDispatch?.(entry.call.name, requiredArgs);
2209
- if (rejection)
2210
- requiredDispatchRejections.set(entry.call.id, { name: entry.call.name, args: requiredArgs, message: rejection });
2218
+ if (!rejection)
2219
+ continue;
2220
+ const rejectionKey = `${entry.call.name}\0${rejection}`;
2221
+ const priorRejections = requiredDispatchRejectionCounts.get(rejectionKey) ?? 0;
2222
+ if (priorRejections >= 2) {
2223
+ (0, runtime_1.emitNervesEvent)({
2224
+ level: "warn", component: "engine", event: "engine.required_tool_dispatch_advisory",
2225
+ message: "required tool dependency rejected twice this turn; letting the call through",
2226
+ meta: { toolName: entry.call.name },
2227
+ });
2228
+ continue;
2229
+ }
2230
+ requiredDispatchRejectionCounts.set(rejectionKey, priorRejections + 1);
2231
+ requiredDispatchRejections.set(entry.call.id, { name: entry.call.name, args: requiredArgs, message: rejection });
2211
2232
  }
2212
2233
  if (requiredDispatchRejections.size > 0) {
2213
2234
  streamCallbackBuffer?.discard();
@@ -813,8 +813,13 @@ function recordExternalEventInternal(input, options = {}) {
813
813
  && existing.recoveryGrant?.generation === existing.generation && existing.failureProvenance !== undefined
814
814
  && Date.parse(now) - Date.parse(existing.failureProvenance.failedAt) >= EXHAUSTED_RECOVERY_REOPEN_MS
815
815
  && Math.floor(Date.parse(now) / 3_600_000) % EXHAUSTED_RECOVERY_SLOTS === exhaustedRecoverySlot(existing);
816
- const shouldWake = !quietInitialReceipt && (!existing || quietBaselineChanged
816
+ // A recovery is only news to an owner who was told about the incident, or to a turn that asked to watch for it; any other recovery settles here without a model turn.
817
+ const silentRecovery = input.transition === "recovered" && existing !== null
818
+ && existing.disposition?.decision !== "ask" && existing.disposition?.decision !== "report" && existing.disposition?.nextWake.kind !== "on_recovery";
819
+ const settledRecoveryReopened = existing?.executionState === "handled" && !existing.disposition && existing.transition === "recovered" && input.transition !== "recovered";
820
+ const shouldWake = !quietInitialReceipt && !silentRecovery && (!existing || quietBaselineChanged || settledRecoveryReopened
817
821
  || (existing.disposition ? predicateMatches(existing.disposition, input, revision) : existing.executionState === "dead_letter" && (revision !== existing.observationRevision || exhaustedRecoveryReopens)));
822
+ const settles = quietInitialReceipt || silentRecovery;
818
823
  const generation = existing ? existing.generation + (shouldWake ? 1 : 0) : 1;
819
824
  const record = {
820
825
  schemaVersion: 2,
@@ -834,17 +839,17 @@ function recordExternalEventInternal(input, options = {}) {
834
839
  observationRevision: revision,
835
840
  observationDigest: digest,
836
841
  transition: input.transition ?? (existing ? "unchanged" : "opened"),
837
- executionState: quietInitialReceipt ? "handled" : shouldWake ? "received" : existing.executionState,
842
+ executionState: settles ? "handled" : shouldWake ? "received" : existing.executionState,
838
843
  generation,
839
- attemptCount: shouldWake || quietInitialReceipt ? 0 : existing.attemptCount,
840
- claimOwner: shouldWake || quietInitialReceipt ? null : existing.claimOwner,
841
- claimExpiresAt: shouldWake || quietInitialReceipt ? null : existing.claimExpiresAt,
842
- nextAttemptAt: shouldWake || quietInitialReceipt ? null : existing.nextAttemptAt,
843
- lastError: shouldWake || quietInitialReceipt ? null : existing.lastError,
844
- ...(!shouldWake && !quietInitialReceipt && existing?.failureProvenance ? { failureProvenance: existing.failureProvenance } : {}),
845
- ...(!shouldWake && !quietInitialReceipt && existing?.recoveryGrant ? { recoveryGrant: existing.recoveryGrant } : {}),
846
- disposition: shouldWake || quietInitialReceipt ? null : existing.disposition,
847
- pendingObservation: shouldWake || quietInitialReceipt ? null : existing.pendingObservation,
844
+ attemptCount: shouldWake || settles ? 0 : existing.attemptCount,
845
+ claimOwner: shouldWake || settles ? null : existing.claimOwner,
846
+ claimExpiresAt: shouldWake || settles ? null : existing.claimExpiresAt,
847
+ nextAttemptAt: shouldWake || settles ? null : existing.nextAttemptAt,
848
+ lastError: shouldWake || settles ? null : existing.lastError,
849
+ ...(!shouldWake && !settles && existing?.failureProvenance ? { failureProvenance: existing.failureProvenance } : {}),
850
+ ...(!shouldWake && !settles && existing?.recoveryGrant ? { recoveryGrant: existing.recoveryGrant } : {}),
851
+ disposition: shouldWake || settles ? null : existing.disposition,
852
+ pendingObservation: shouldWake || settles ? null : existing.pendingObservation,
848
853
  dispatchEnabled: options.dispatchEnabled ?? existing?.dispatchEnabled ?? true,
849
854
  shouldWake,
850
855
  ...(persistedRetention ? { retentionSummary: persistedRetention } : {}),
@@ -1399,7 +1404,10 @@ function settleExternalEventFailureIfOwned(recordPath, input) {
1399
1404
  || record.generation !== input.expectedGeneration) {
1400
1405
  return { settled: false, record };
1401
1406
  }
1402
- const settled = commitMutation(recordPath, retryState(record, now, 5, 1_000, input.error, input.failureClass), now);
1407
+ const next = input.deferUntil
1408
+ ? { ...record, executionState: "retry_wait", claimOwner: null, claimExpiresAt: null, nextAttemptAt: input.deferUntil, attemptCount: Math.max(0, record.attemptCount - 1), lastError: input.error.slice(0, 1_000), shouldWake: false }
1409
+ : retryState(record, now, 5, 1_000, input.error, input.failureClass);
1410
+ const settled = commitMutation(recordPath, next, now);
1403
1411
  return { settled: true, record: settled };
1404
1412
  });
1405
1413
  }
@@ -185,7 +185,7 @@ function summarizeArr(service, queuePayload, missingPayload, historyPayload, now
185
185
  id, area: "downloads", severity: "warn", refs,
186
186
  next: series ? "fix" : "owner",
187
187
  summary: `${label} download of ${name} is stuck at ${percent}% (${release}): ${problem}`,
188
- fingerprint: fingerprintOf(id, problem.startsWith("no progress") ? "frozen" : problem),
188
+ fingerprint: fingerprintOf(id, "stuck"),
189
189
  ...(series ? { fix: { tool: "media_fill_missing", args: { service_id: subjectId }, note: "keeps the partial download; replaces it only when a seeded alternative is grabbed" } } : {}),
190
190
  });
191
191
  }
@@ -228,7 +228,7 @@ function summarizeArr(service, queuePayload, missingPayload, historyPayload, now
228
228
  findings.push({
229
229
  id, area: "missing", severity: "info", next: "fix",
230
230
  summary: series ? `${entry.name}: ${entry.count} monitored episode${entry.count === 1 ? "" : "s"} missing, the oldest aired ${days} days ago` : `${entry.name}: monitored movie still missing after ${days} days`,
231
- fingerprint: fingerprintOf(id, String(entry.count)),
231
+ fingerprint: fingerprintOf(id, "missing"),
232
232
  fix: series
233
233
  ? { tool: "media_fill_missing", args: { service_id: subjectId }, note: "searches, grabs a seeded release, imports what finished" }
234
234
  : { tool: "media_search_now", args: { kind: "movie", service_id: subjectId }, note: "triggers a Radarr search" },
@@ -272,7 +272,7 @@ async function sweepHost(sanctuary, policyStates, nowMs, findings) {
272
272
  const capacity = (id, label, percent) => {
273
273
  if (percent === null || percent < exports.CAPACITY_PERCENT)
274
274
  return;
275
- findings.push({ id, area: "disk", severity: percent >= exports.CRITICAL_CAPACITY_PERCENT ? "critical" : "warn", next: "owner", summary: `${label} is ${percent}% full`, fingerprint: fingerprintOf(id, String(Math.floor(percent))) });
275
+ findings.push({ id, area: "disk", severity: percent >= exports.CRITICAL_CAPACITY_PERCENT ? "critical" : "warn", next: "owner", summary: `${label} is ${percent}% full`, fingerprint: fingerprintOf(id, percent >= exports.CRITICAL_CAPACITY_PERCENT ? "critical" : "full") });
276
276
  };
277
277
  capacity("disk:array:capacity", "the array", summary.arrayUsedPercent);
278
278
  for (const share of list(storage?.shares).map(dict))
@@ -285,7 +285,7 @@ async function sweepHost(sanctuary, policyStates, nowMs, findings) {
285
285
  findings.push({ id: `${id}:smart`, area: "disk", severity: "critical", next: "owner", summary: `${text(disk.name)} reports a failed SMART status`, fingerprint: fingerprintOf(`${id}:smart`, "failed") });
286
286
  const temperature = num(disk.temperatureC);
287
287
  if (temperature !== null && temperature >= exports.TEMPERATURE_C)
288
- findings.push({ id: `${id}:temperature`, area: "disk", severity: "warn", next: "owner", summary: `${text(disk.name)} is ${temperature}C`, fingerprint: fingerprintOf(`${id}:temperature`, String(Math.floor(temperature / 5))) });
288
+ findings.push({ id: `${id}:temperature`, area: "disk", severity: "warn", next: "owner", summary: `${text(disk.name)} is ${temperature}C`, fingerprint: fingerprintOf(`${id}:temperature`, "hot") });
289
289
  }
290
290
  const parity = dict(disks?.parity);
291
291
  summary.parity = text(parity.result) || null;
@@ -294,7 +294,7 @@ async function sweepHost(sanctuary, policyStates, nowMs, findings) {
294
294
  if (disks) {
295
295
  const result = summary.parity;
296
296
  if (result === "failed")
297
- findings.push({ id: "parity:result", area: "parity", severity: "critical", next: "owner", summary: `the last parity check failed (${num(parity.errors) ?? "unknown"} errors)`, fingerprint: fingerprintOf("parity:result", `failed:${num(parity.errors)}`) });
297
+ findings.push({ id: "parity:result", area: "parity", severity: "critical", next: "owner", summary: `the last parity check failed (${num(parity.errors) ?? "unknown"} errors)`, fingerprint: fingerprintOf("parity:result", "failed") });
298
298
  else if (result !== "in_progress" && (summary.parityAgeDays === null || summary.parityAgeDays >= exports.PARITY_MAX_AGE_DAYS))
299
299
  findings.push({ id: "parity:age", area: "parity", severity: "warn", next: "owner", summary: summary.parityAgeDays === null ? "parity check age is unknown" : `the last parity check was ${summary.parityAgeDays} days ago`, fingerprint: fingerprintOf("parity:age", summary.parityAgeDays === null ? "unknown" : "old") });
300
300
  }
@@ -434,10 +434,12 @@ async function runHouseSweep(deps) {
434
434
  })
435
435
  .sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.id.localeCompare(b.id));
436
436
  if (!deps.replay) {
437
- // A finding that is gone is forgotten, so it is told again if it comes back.
438
- // ...but only when its source was readable: an outage must not make every finding look new on recovery.
437
+ // A finding that has been gone for REMIND_DAYS is forgotten, so it is told again if it comes back after that.
438
+ // A brief disappearance (or an unreadable source) keeps the entry, so a flickering finding never returns as "new".
439
439
  const present = new Set(ordered.map((finding) => finding.id));
440
- const kept = Object.fromEntries(Object.entries(ledger).filter(([id]) => present.has(id) || sourcesFor(id).some((source) => sources[source] !== "ok")));
440
+ const kept = Object.fromEntries(Object.entries(ledger).filter(([id, told]) => present.has(id)
441
+ || sourcesFor(id).some((source) => sources[source] !== "ok")
442
+ || nowMs - Date.parse(told.reportedAt) < exports.REMIND_DAYS * DAY_MS));
441
443
  if (Object.keys(kept).length !== Object.keys(ledger).length)
442
444
  (0, exports.writeLedger)(deps.agentRoot, kept);
443
445
  }
@@ -50,11 +50,12 @@ const externalEventDispositionProperties = {
50
50
  expectedGeneration: { type: "number", description: "Exact generation shown in the external-event turn" },
51
51
  classifiedRevision: { type: "string", description: "Exact observation revision investigated in this turn" },
52
52
  classification: { type: "string", enum: ["expected", "needs_attention", "adopted", "snoozed", "dismissed_until_change", "resolved"] },
53
- stewardPolicyKind: { type: "string", enum: ["current", "none"], description: "Use current with the exact live policy key/version, or none only for a fresh observation with no applicable policy" },
53
+ stewardPolicyKind: { type: "string", enum: ["current", "none"], description: "Use current with the exact live policy key/version when one applies; otherwise use none. Any event may be closed with none." },
54
54
  stewardPolicyKey: { type: "string", description: "Exact current policy key used for this decision" },
55
55
  stewardPolicyVersion: { type: "number", description: "Exact current policy version used for this decision" },
56
56
  decision: { type: "string", enum: ["silent", "act", "ask", "report"] },
57
- reason: { type: "string", description: "Short plain-language reason for the decision" },
57
+ reason: { type: "string", description: "Internal audit note for the decision; never shown to the owner" },
58
+ owner_message: { type: "string", description: "Required for ask or report: the exact short plain message the owner reads, as you would text a friend. No ids, leases, generations, revisions or receipts." },
58
59
  nextWake: { type: "string", enum: ["on_change", "on_escalation", "on_recovery", "at"], description: "Use on_change unless you already called await_condition for this exact event and are supplying its awaitId" },
59
60
  wakeAt: { type: "string", description: "ISO time required only when nextWake=at" },
60
61
  awaitId: { type: "string", description: "Existing await_condition receipt required when nextWake=at; never invent one" },
@@ -70,6 +71,8 @@ const externalEventDispositionBatchSchema = {
70
71
  description: "All exact leases from one coalesced external-event turn",
71
72
  items: { type: "object", properties: externalEventDispositionProperties, required: externalEventDispositionRequired, additionalProperties: false },
72
73
  };
74
+ // The owner reads owner_message verbatim, so internal bookkeeping words never reach them.
75
+ const OWNER_MESSAGE_FORBIDDEN = /\b(generation|lease|revision|receipt|disposition|claimOwner|recordPath)s?\b|[\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/iu;
73
76
  function prepareExternalEventDisposition(a, ctx) {
74
77
  const agentName = (0, identity_1.getAgentName)();
75
78
  const agentEventRoot = path.resolve((0, router_1.getExternalEventRoot)(), agentName);
@@ -99,6 +102,7 @@ function prepareExternalEventDisposition(a, ctx) {
99
102
  const policyVersion = Number(a.stewardPolicyVersion);
100
103
  const policyKind = String(a.stewardPolicyKind ?? "");
101
104
  const reason = String(a.reason ?? "").trim();
105
+ const ownerText = String(a.owner_message ?? "").trim();
102
106
  if (!reason || (policyKind !== "none" && (!Number.isSafeInteger(policyVersion) || policyVersion < 1))
103
107
  || !["current", "none"].includes(policyKind)
104
108
  || !["expected", "needs_attention", "adopted", "snoozed", "dismissed_until_change", "resolved"].includes(classification)
@@ -113,8 +117,6 @@ function prepareExternalEventDisposition(a, ctx) {
113
117
  const policy = (0, steward_policy_1.readStewardPolicy)(agentRoot);
114
118
  let stewardPolicy;
115
119
  if (policyKind === "none") {
116
- if (record.transition !== "opened")
117
- throw new Error("External event no-policy disposition requires a fresh observation");
118
120
  stewardPolicy = { kind: "none" };
119
121
  }
120
122
  else {
@@ -178,15 +180,19 @@ function prepareExternalEventDisposition(a, ctx) {
178
180
  finish: async () => {
179
181
  let ownerMessage;
180
182
  if (decision === "ask" || decision === "report") {
181
- if (Buffer.byteLength(reason, "utf8") > 1_200)
183
+ if (!ownerText)
184
+ throw new Error("ask and report need owner_message: the short plain text the owner will read");
185
+ if (Buffer.byteLength(ownerText, "utf8") > 1_200)
182
186
  throw new Error("External event owner message must be phone-sized");
187
+ if (OWNER_MESSAGE_FORBIDDEN.test(ownerText))
188
+ throw new Error("owner_message must be plain words for the owner, with no internal ids, leases, generations, revisions, receipts or hidden characters");
183
189
  if (!ctx?.externalEventEffects)
184
190
  throw new Error("External event owner delivery is unavailable");
185
191
  // The whole dispatched batch shares one owner message; the delivery port settles later decisions as covered by it.
186
192
  const relatedEvents = [turnContext, ...(turnContext.relatedEvents ?? [])]
187
193
  .filter((event) => path.resolve(event.recordPath) !== recordPath)
188
194
  .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 });
195
+ const delivered = await ctx.externalEventEffects.deliverOwnerDecision({ source: record.source, eventId: record.eventId, generation: record.generation, text: ownerText, relatedEvents });
190
196
  ownerMessage = { status: delivered.status, effectKey: delivered.effectKey };
191
197
  }
192
198
  const handled = (0, router_1.commitExternalEventDisposition)(recordPath, {
@@ -220,7 +226,7 @@ exports.continuityToolDefinitions = [
220
226
  type: "function",
221
227
  function: {
222
228
  name: "external_event_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.",
229
+ description: "Classify the exact external-event generation I just investigated. An ask or report is the sole owner-delivery path and sends owner_message once for this receipt; reason stays internal. 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 owner_message to cover every listed incident.",
224
230
  parameters: {
225
231
  type: "object",
226
232
  properties: {
@@ -396,7 +402,7 @@ exports.continuityToolDefinitions = [
396
402
  incidentKey: { type: "string", description: "Stable incident key within the source" },
397
403
  classifiedRevision: { type: "string", description: "Observation revision classified by the agent" },
398
404
  correlationKey: { type: "string", description: "Optional key relating several incidents to one concern" },
399
- expectedUpdatedAt: { type: "string", description: "Optional Care updatedAt value for CAS fencing" },
405
+ expectedUpdatedAt: { type: "string", description: "The Care's current updatedAt, copied from the last care_manage result. Required for bind_incident and resolve_incident; optional for update." },
400
406
  currentRisk: { type: "string", description: "Current material risk for this Care, or an empty string to clear it" },
401
407
  nextCheckAt: { type: "string", description: "Exact ISO time of the next owned check" },
402
408
  },
@@ -442,7 +448,7 @@ exports.continuityToolDefinitions = [
442
448
  }
443
449
  else if (a.action === "bind_incident") {
444
450
  if (!a.expectedUpdatedAt)
445
- throw new Error("Care incident binding requires expectedUpdatedAt");
451
+ throw new Error("bind_incident requires expectedUpdatedAt: copy the Care's current updatedAt from the last care_manage result");
446
452
  result = (0, cares_1.bindCareIncident)(agentRoot, a.id, {
447
453
  source: a.source,
448
454
  incidentKey: a.incidentKey,
@@ -452,7 +458,7 @@ exports.continuityToolDefinitions = [
452
458
  }
453
459
  else if (a.action === "resolve_incident") {
454
460
  if (!a.expectedUpdatedAt)
455
- throw new Error("Care incident resolution requires expectedUpdatedAt");
461
+ throw new Error("resolve_incident requires expectedUpdatedAt: copy the Care's current updatedAt from the last care_manage result");
456
462
  result = (0, cares_1.resolveCareIncident)(agentRoot, a.id, {
457
463
  source: a.source,
458
464
  incidentKey: a.incidentKey,
@@ -40,6 +40,7 @@ const fs = __importStar(require("fs"));
40
40
  const path = __importStar(require("path"));
41
41
  const private_runtime_1 = require("./private-runtime");
42
42
  const router_1 = require("../heart/external-events/router");
43
+ const telegram_handoff_1 = require("./telegram-handoff");
43
44
  const runtime_1 = require("../nerves/runtime");
44
45
  const identity_1 = require("../heart/identity");
45
46
  const pending_1 = require("../mind/pending");
@@ -167,6 +168,50 @@ function reserveHabitAutonomyBudget(habitRun, nowIso) {
167
168
  now: nowIso,
168
169
  }, (0, autonomy_budget_1.resolveAutonomyBudgetPolicy)(habitRun.agentRoot, agentName));
169
170
  }
171
+ /**
172
+ * External-event turns are paid model work, so they draw on their own daily allowance (the agent's
173
+ * habitPaidTurnsPerDay, counted under the "external-events" name so a busy event day cannot starve a
174
+ * habit) and share one storm breaker: three failed event turns within ten minutes pause event turns
175
+ * for thirty minutes.
176
+ */
177
+ const EXTERNAL_EVENT_BUDGET_NAME = "external-events";
178
+ function externalEventStorm(agentName) {
179
+ return {
180
+ agent: agentName,
181
+ triggerType: "habit",
182
+ sourceKind: "private-runtime",
183
+ senseOrHabit: EXTERNAL_EVENT_BUDGET_NAME,
184
+ provider: "model",
185
+ target: { kind: EXTERNAL_EVENT_BUDGET_NAME },
186
+ normalizedErrorName: "external_event_turn_failed",
187
+ normalizedErrorCode: "turn_failed",
188
+ codeLocation: "private-runtime-worker",
189
+ idempotencyBucket: EXTERNAL_EVENT_BUDGET_NAME,
190
+ };
191
+ }
192
+ /** A blocked event turn waits this long (the breaker's block length) and keeps its retry attempts. */
193
+ const EXTERNAL_EVENT_BLOCKED_DEFER_MS = 30 * 60_000;
194
+ function reserveExternalEventAutonomyBudget(event, nowIso) {
195
+ // Disk, parity and array failures always get a turn: the owner must hear about them even on a busy day.
196
+ if ([event, ...(event.relatedEvents ?? [])].some((member) => (0, telegram_handoff_1.isCriticalNoticeKey)(member.eventId)))
197
+ return null;
198
+ const agentRoot = (0, identity_1.getAgentRoot)();
199
+ const agentName = (0, identity_1.getAgentName)();
200
+ return (0, autonomy_budget_1.reserveAutonomyBudget)(agentRoot, {
201
+ agent: agentName,
202
+ triggerType: "habit",
203
+ sourceKind: "private-runtime",
204
+ senseOrHabit: EXTERNAL_EVENT_BUDGET_NAME,
205
+ target: { kind: EXTERNAL_EVENT_BUDGET_NAME },
206
+ idempotencyKey: `event:${event.recordPath}:${event.generation}:${event.claimOwner}`,
207
+ now: nowIso,
208
+ storm: externalEventStorm(agentName),
209
+ }, (0, autonomy_budget_1.resolveAutonomyBudgetPolicy)(agentRoot, agentName));
210
+ }
211
+ function recordExternalEventTurnFailure(nowIso) {
212
+ const agentRoot = (0, identity_1.getAgentRoot)();
213
+ (0, autonomy_budget_1.recordAutonomyFailure)(agentRoot, { ...externalEventStorm((0, identity_1.getAgentName)()), occurredAt: nowIso }, (0, autonomy_budget_1.resolveAutonomyBudgetPolicy)(agentRoot, (0, identity_1.getAgentName)()));
214
+ }
170
215
  /**
171
216
  * Habit recursion detector thresholds. This protects against the *external*
172
217
  * IPC self-loop where heartbeat-shaped messages get re-issued faster
@@ -537,7 +582,9 @@ function createPrivateRuntimeWorker(runTurn = (options) => (0, private_runtime_1
537
582
  const turnStartedAt = new Date(nowSource()).toISOString();
538
583
  const autonomyDecision = currentHabitRun && !currentHabitRun.blockedReason
539
584
  ? reserveHabitAutonomyBudget(currentHabitRun, turnStartedAt)
540
- : null;
585
+ : currentExternalEvent
586
+ ? reserveExternalEventAutonomyBudget(currentExternalEvent, turnStartedAt)
587
+ : null;
541
588
  if (currentHabitRun)
542
589
  recordHabitStartIfNeeded(currentHabitRun);
543
590
  if (currentHabitRun?.blockedReason) {
@@ -620,15 +667,20 @@ function createPrivateRuntimeWorker(runTurn = (options) => (0, private_runtime_1
620
667
  : blockedAutonomyTurn || turnOutcome === "blocked" || turnOutcome === "suspended"
621
668
  ? "external-event turn was blocked"
622
669
  : "external-event turn completed without a terminal disposition";
670
+ const settledAt = nowSource();
671
+ const deferUntil = blockedAutonomyTurn ? new Date(settledAt + EXTERNAL_EVENT_BLOCKED_DEFER_MS).toISOString() : undefined;
623
672
  let settledIncomplete = false;
624
673
  for (const member of [currentExternalEvent, ...(currentExternalEvent.relatedEvents ?? [])]) {
625
674
  const result = (0, router_1.settleExternalEventFailureIfOwned)(member.recordPath, {
626
675
  owner: member.claimOwner,
627
676
  expectedGeneration: member.generation,
628
677
  error: failureReason,
678
+ ...(deferUntil ? { deferUntil } : {}),
629
679
  });
630
680
  settledIncomplete = result.settled || settledIncomplete;
631
681
  }
682
+ if (!blockedAutonomyTurn && (turnErrors.length > 0 || turnOutcome === "errored" || settledIncomplete))
683
+ recordExternalEventTurnFailure(new Date(settledAt).toISOString());
632
684
  if (settledIncomplete && turnErrors.length === 0)
633
685
  turnErrors.push(failureReason);
634
686
  }
@@ -547,7 +547,7 @@ async function runSanctuaryHealthAcceptanceProbe(input, dependencies = createSan
547
547
  }
548
548
  const runPhase = async (name, trigger, fixtureStatus, fetchImpl, now) => {
549
549
  const sweep = (0, sanctuary_health_1.createSanctuaryHealthSweep)({
550
- toolContext: dependencies.toolContext, statePath, fetch: fetchImpl, now: () => now, lease,
550
+ toolContext: dependencies.toolContext, statePath, fetch: fetchImpl, now: () => now, lease, endpointDebounce: false,
551
551
  acceptanceEventMeta: () => ({ scenarioHandleDigest: input.scenarioHandleDigest }),
552
552
  });
553
553
  await (0, sanctuary_health_runner_1.runSanctuaryHealthHabit)("sanctuary", {
@@ -226,6 +226,7 @@ function load(filePath) {
226
226
  lastDigestDay: value.lastDigestDay,
227
227
  updatedAt: value.updatedAt,
228
228
  ...(value.pendingRecovered?.length ? { pendingRecovered: value.pendingRecovered } : {}),
229
+ ...(endpointFailuresOf(value.endpointFailures) ? { endpointFailures: endpointFailuresOf(value.endpointFailures) } : {}),
229
230
  outbox: value.outbox ? { ...value.outbox, kind: value.outbox.kind ?? "legacy_unknown" } : null,
230
231
  indeterminateDeliveries: (value.indeterminateDeliveries ?? []).map((delivery) => ({ ...delivery, kind: delivery.kind ?? "legacy_unknown" })),
231
232
  deliveredReceipts: (value.deliveredReceipts ?? []).map((receipt) => ({ ...receipt, kind: receipt.kind ?? "legacy_unknown" })),
@@ -242,6 +243,13 @@ function load(filePath) {
242
243
  function readSanctuaryHealthState(filePath) {
243
244
  return load(filePath);
244
245
  }
246
+ // A malformed failure counter only delays an endpoint incident by one sweep, so it is dropped rather than treated as corrupt state.
247
+ function endpointFailuresOf(value) {
248
+ if (!value || typeof value !== "object" || Array.isArray(value))
249
+ return null;
250
+ const entries = Object.entries(value).filter(([, count]) => Number.isSafeInteger(count) && count > 0);
251
+ return entries.length > 0 ? Object.fromEntries(entries) : null;
252
+ }
245
253
  function save(filePath, state) {
246
254
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
247
255
  const temporary = `${filePath}.tmp-${process.pid}-${(0, node_crypto_1.randomUUID)()}`;
@@ -295,6 +303,16 @@ async function probeSanctuaryEndpoint(url, fetchImpl = fetch) {
295
303
  }
296
304
  function createSanctuaryHealthSweep(options) {
297
305
  const fetchImpl = options.fetch ?? fetch;
306
+ const endpointRetryDelayMs = options.endpointRetryDelayMs ?? 3_000;
307
+ const endpointDebounce = options.endpointDebounce ?? true;
308
+ const endpointFailuresToOpen = endpointDebounce ? 2 : 1;
309
+ const probeWithRetry = async (url) => {
310
+ const first = await probeSanctuaryEndpoint(url, fetchImpl);
311
+ if (first.ok || !endpointDebounce)
312
+ return first;
313
+ await new Promise((resolve) => setTimeout(resolve, endpointRetryDelayMs));
314
+ return probeSanctuaryEndpoint(url, fetchImpl);
315
+ };
298
316
  const now = options.now ?? (() => new Date());
299
317
  const acceptanceEventMeta = options.acceptanceEventMeta ?? (() => (0, sanctuary_acceptance_marker_1.sanctuaryAcceptanceEventMeta)("sanctuary"));
300
318
  const runSweep = async () => {
@@ -313,7 +331,7 @@ function createSanctuaryHealthSweep(options) {
313
331
  throw new Error("Sanctuary health runtime is unavailable");
314
332
  const [containersResult, storageResult, disksResult, notificationsResult, endpoints] = await Promise.all([
315
333
  runtime.listContainers(), runtime.getStorage(), runtime.getDisks(), runtime.getNotifications(),
316
- Promise.all(exports.SANCTUARY_PUBLIC_ENDPOINTS.map((url) => probeSanctuaryEndpoint(url, fetchImpl))),
334
+ Promise.all(exports.SANCTUARY_PUBLIC_ENDPOINTS.map((url) => probeWithRetry(url))),
317
335
  ]);
318
336
  const incidents = new Map();
319
337
  const add = (id, rawSummary) => {
@@ -364,13 +382,20 @@ function createSanctuaryHealthSweep(options) {
364
382
  const notifications = record(notificationsResult)?.ok ? record(record(notificationsResult)?.data)?.unacknowledged : null;
365
383
  if (!Array.isArray(notifications))
366
384
  add("notifications:unavailable", "notification status is unavailable");
385
+ // Informational (Unraid "normal") notifications are not incidents; warnings and alerts are.
367
386
  else
368
387
  for (const item of notifications)
369
- add(`notification:${item.id}`, `unacknowledged notification: ${item.title || item.id}`);
370
- for (const endpoint of endpoints)
371
- if (!endpoint.ok)
372
- add(`endpoint:${endpoint.url}`, `${endpoint.url} returned ${endpoint.status || "no response"}`);
388
+ if (item.severity !== "info")
389
+ add(`notification:${item.id}`, `unacknowledged notification: ${item.title || item.id}`);
373
390
  const previous = load(options.statePath);
391
+ const endpointFailures = {};
392
+ for (const endpoint of endpoints) {
393
+ if (endpoint.ok)
394
+ continue;
395
+ endpointFailures[endpoint.url] = (previous.endpointFailures?.[endpoint.url] ?? 0) + 1;
396
+ if (endpointFailures[endpoint.url] >= endpointFailuresToOpen)
397
+ add(`endpoint:${endpoint.url}`, `${endpoint.url} returned ${endpoint.status || "no response"}`);
398
+ }
374
399
  const current = Object.fromEntries([...incidents.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([id, incident]) => {
375
400
  const prior = previous.incidents[id];
376
401
  const transition = !prior ? "opened" : prior.observationRevision === incident.observationRevision ? "unchanged" : "changed";
@@ -401,6 +426,7 @@ function createSanctuaryHealthSweep(options) {
401
426
  lastDigestDay: null,
402
427
  updatedAt: now().toISOString(),
403
428
  ...(unacknowledgedRecovered.length > 0 ? { pendingRecovered: unacknowledgedRecovered } : {}),
429
+ ...(Object.keys(endpointFailures).length > 0 ? { endpointFailures } : {}),
404
430
  outbox: null,
405
431
  indeterminateDeliveries: [],
406
432
  deliveredReceipts: previous.deliveredReceipts,
@@ -33,10 +33,17 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.OWNER_CHAT_HANDOFF_MS = void 0;
36
+ exports.QUIET_HOURS = exports.PROACTIVE_DAILY_CAP = exports.OWNER_CHAT_HANDOFF_MS = void 0;
37
37
  exports.ownerChatHandedOff = ownerChatHandedOff;
38
38
  exports.readHeldNotices = readHeldNotices;
39
39
  exports.writeHeldNotices = writeHeldNotices;
40
+ exports.isQuietHour = isQuietHour;
41
+ exports.isCriticalNoticeKey = isCriticalNoticeKey;
42
+ exports.proactiveHoldReason = proactiveHoldReason;
43
+ exports.recordProactiveSend = recordProactiveSend;
44
+ exports.foldHeldNotices = foldHeldNotices;
45
+ exports.readReleasedHeldKeys = readReleasedHeldKeys;
46
+ exports.recordReleasedHeldKeys = recordReleasedHeldKeys;
40
47
  /**
41
48
  * Ari sometimes hands his Telegram chat to someone else ("I'm going to hand you to my wife, Rachel"). For a while after that,
42
49
  * the chat is that person's conversation, so house notices (an outage, a recovery) wait instead of landing in it
@@ -45,6 +52,7 @@ exports.writeHeldNotices = writeHeldNotices;
45
52
  */
46
53
  const fs = __importStar(require("node:fs"));
47
54
  const path = __importStar(require("node:path"));
55
+ const node_crypto_1 = require("node:crypto");
48
56
  const session_events_1 = require("../heart/session-events");
49
57
  const runtime_1 = require("../nerves/runtime");
50
58
  exports.OWNER_CHAT_HANDOFF_MS = 2 * 60 * 60 * 1000;
@@ -78,3 +86,69 @@ function writeHeldNotices(filePath, notices) {
78
86
  fs.renameSync(temporary, filePath);
79
87
  (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.telegram_held_notices_saved", message: "saved the house notices held during a handoff", meta: { held: notices.length } });
80
88
  }
89
+ /**
90
+ * Proactive pacing: at most PROACTIVE_DAILY_CAP unprompted owner messages per local day, and none in quiet hours
91
+ * (23:00-08:00 local), except critical ones (array or disk failure, parity errors). A held message waits in the same
92
+ * held-notice file and goes out, folded into one message, at the next allowed moment.
93
+ */
94
+ exports.PROACTIVE_DAILY_CAP = 3;
95
+ exports.QUIET_HOURS = { start: 23, end: 8 };
96
+ const CRITICAL_NOTICE = /\b(?:disk:[^"\s]*:smart|parity:|disk:array:state)/u;
97
+ function localParts(now, timeZone) {
98
+ const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", hourCycle: "h23" })
99
+ .formatToParts(now).map((part) => [part.type, part.value]));
100
+ return { day: `${parts.year}-${parts.month}-${parts.day}`, hour: Number(parts.hour) };
101
+ }
102
+ function isQuietHour(now, pacing) {
103
+ const { hour } = localParts(now, pacing.timeZone);
104
+ return hour >= exports.QUIET_HOURS.start || hour < exports.QUIET_HOURS.end;
105
+ }
106
+ function isCriticalNoticeKey(key) {
107
+ return CRITICAL_NOTICE.test(key);
108
+ }
109
+ function readProactiveLedger(filePath) {
110
+ try {
111
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
112
+ return typeof parsed.day === "string" && Number.isSafeInteger(parsed.count) ? { day: parsed.day, count: parsed.count } : null;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ /** Why a non-critical proactive message must wait now, or null when it may go. */
119
+ function proactiveHoldReason(ledgerPath, now, pacing) {
120
+ if (isQuietHour(now, pacing))
121
+ return "quiet_hours";
122
+ const ledger = readProactiveLedger(ledgerPath);
123
+ return ledger?.day === localParts(now, pacing.timeZone).day && ledger.count >= exports.PROACTIVE_DAILY_CAP ? "daily_cap" : null;
124
+ }
125
+ function recordProactiveSend(ledgerPath, now, pacing) {
126
+ const day = localParts(now, pacing.timeZone).day;
127
+ const ledger = readProactiveLedger(ledgerPath);
128
+ fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });
129
+ fs.writeFileSync(ledgerPath, JSON.stringify({ day, count: ledger?.day === day ? ledger.count + 1 : 1 }), { mode: 0o600 });
130
+ }
131
+ /** Several held notices go out as one message so a quiet night never becomes a morning burst. */
132
+ function foldHeldNotices(held) {
133
+ if (held.length === 1)
134
+ return held[0];
135
+ return { key: `held-batch:${(0, node_crypto_1.createHash)("sha256").update(held.map((notice) => notice.key).join("\0")).digest("hex").slice(0, 32)}`, text: ["While you were away:", ...held.map((notice) => `- ${notice.text}`)].join("\n") };
136
+ }
137
+ const RELEASED_KEYS_LIMIT = 500;
138
+ /** Keys of held notices that went out inside a folded batch, so a retried decision sees its message already exists. */
139
+ function readReleasedHeldKeys(filePath) {
140
+ try {
141
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
142
+ return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === "string") : [];
143
+ }
144
+ catch {
145
+ return [];
146
+ }
147
+ }
148
+ function recordReleasedHeldKeys(filePath, keys) {
149
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
150
+ const next = [...readReleasedHeldKeys(filePath).filter((key) => !keys.includes(key)), ...keys].slice(-RELEASED_KEYS_LIMIT);
151
+ const temporary = `${filePath}.${process.pid}.tmp`;
152
+ fs.writeFileSync(temporary, JSON.stringify(next), { mode: 0o600 });
153
+ fs.renameSync(temporary, filePath);
154
+ }
@@ -1137,13 +1137,22 @@ function createTelegramSenseApp(options) {
1137
1137
  const sessionPath = (0, shared_turn_1.getSenseSessionPath)(options.agentName, configuredOwnerFriendId, "telegram", configuredOwnerSessionKey, agentRoot);
1138
1138
  return (0, telegram_handoff_1.ownerChatHandedOff)((0, session_events_1.loadSessionEnvelopeFile)(sessionPath)?.events ?? [], Date.now());
1139
1139
  };
1140
- const holdNoticeDuringHandoff = (text, key) => {
1141
- if (!ownerChatIsHandedOff())
1140
+ const proactiveLedgerPath = path.join(agentRoot, "state", "telegram", "proactive-sends.json");
1141
+ const countProactiveSend = () => { if (options.proactivePacing)
1142
+ (0, telegram_handoff_1.recordProactiveSend)(proactiveLedgerPath, new Date(), options.proactivePacing); };
1143
+ const releasedHeldKeysPath = path.join(agentRoot, "state", "telegram", "released-held-keys.json");
1144
+ const holdNoticeDuringHandoff = (text, key, critical = (0, telegram_handoff_1.isCriticalNoticeKey)(key)) => {
1145
+ const handedOff = ownerChatIsHandedOff();
1146
+ const pacingReason = !handedOff && options.proactivePacing && !critical ? (0, telegram_handoff_1.proactiveHoldReason)(proactiveLedgerPath, new Date(), options.proactivePacing) : null;
1147
+ if (!handedOff && !pacingReason)
1142
1148
  return false;
1143
1149
  const held = (0, telegram_handoff_1.readHeldNotices)(heldNoticesPath);
1144
1150
  if (!held.some((notice) => notice.key === key))
1145
1151
  (0, telegram_handoff_1.writeHeldNotices)(heldNoticesPath, [...held, { key, text }]);
1146
- (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.telegram_notice_held_for_handoff", message: "held a house notice while the owner's chat is handed to someone else", meta: { agentName: options.agentName } });
1152
+ if (handedOff)
1153
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.telegram_notice_held_for_handoff", message: "held a house notice while the owner's chat is handed to someone else", meta: { agentName: options.agentName } });
1154
+ else
1155
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.telegram_notice_held_for_pacing", message: "held a house notice for quiet hours or the daily message cap", meta: { agentName: options.agentName, reason: pacingReason } });
1147
1156
  return true;
1148
1157
  };
1149
1158
  let heldNoticeRelease = Promise.resolve();
@@ -1156,6 +1165,21 @@ function createTelegramSenseApp(options) {
1156
1165
  if (ownerChatIsHandedOff())
1157
1166
  return;
1158
1167
  const sessionPath = (0, shared_turn_1.getSenseSessionPath)(options.agentName, configuredOwnerFriendId, "telegram", configuredOwnerSessionKey, agentRoot);
1168
+ if (options.proactivePacing) {
1169
+ // Held notices go out as one message, and only when an unprompted message is allowed now (quiet hours and the daily cap).
1170
+ if ((0, telegram_handoff_1.proactiveHoldReason)(proactiveLedgerPath, new Date(), options.proactivePacing))
1171
+ return;
1172
+ /* v8 ignore next -- the caller already saw held notices; only a concurrent release can empty the file before this re-read @preserve */
1173
+ if (held.length === 0)
1174
+ return;
1175
+ const folded = (0, telegram_handoff_1.foldHeldNotices)(held);
1176
+ if (held.length > 1)
1177
+ (0, telegram_handoff_1.recordReleasedHeldKeys)(releasedHeldKeysPath, held.map((notice) => notice.key));
1178
+ (0, telegram_handoff_1.writeHeldNotices)(heldNoticesPath, (0, telegram_handoff_1.readHeldNotices)(heldNoticesPath).filter((entry) => !held.some((notice) => notice.key === entry.key)));
1179
+ await recordAcceptedEffects(sessionPath, [await deliverButlerEffect(folded.text, folded.key, signal)]);
1180
+ countProactiveSend();
1181
+ return;
1182
+ }
1159
1183
  for (const notice of held) {
1160
1184
  (0, telegram_handoff_1.writeHeldNotices)(heldNoticesPath, (0, telegram_handoff_1.readHeldNotices)(heldNoticesPath).filter((entry) => entry.key !== notice.key));
1161
1185
  await recordAcceptedEffects(sessionPath, [await deliverButlerEffect(notice.text, notice.key, signal)]);
@@ -1949,6 +1973,7 @@ function createTelegramSenseApp(options) {
1949
1973
  return;
1950
1974
  await releaseHeldNotices(signal);
1951
1975
  const effect = await deliverButlerEffect(proactiveText, key, signal);
1976
+ countProactiveSend();
1952
1977
  const sessionPath = (0, shared_turn_1.getSenseSessionPath)(options.agentName, configuredOwnerFriendId, "telegram", configuredOwnerSessionKey, agentRoot);
1953
1978
  await recordAcceptedEffects(sessionPath, [effect]);
1954
1979
  });
@@ -1967,7 +1992,9 @@ function createTelegramSenseApp(options) {
1967
1992
  const text = requiredText(input.text, "external event decision");
1968
1993
  const sessionPath = (0, shared_turn_1.getSenseSessionPath)(options.agentName, configuredOwnerFriendId, "telegram", configuredOwnerSessionKey, agentRoot);
1969
1994
  const held = (0, telegram_handoff_1.readHeldNotices)(heldNoticesPath);
1995
+ const releasedKeys = (0, telegram_handoff_1.readReleasedHeldKeys)(releasedHeldKeysPath);
1970
1996
  const ownerMessageExists = (candidate) => held.some((notice) => notice.key === candidate)
1997
+ || releasedKeys.includes(candidate)
1971
1998
  || getEffectJournal().readByIdempotencyKey(candidate) !== null;
1972
1999
  if (ownerMessageExists(key)) {
1973
2000
  // A retry after the message went out (or was held): finish the original send, never send a reworded second one.
@@ -1991,10 +2018,11 @@ function createTelegramSenseApp(options) {
1991
2018
  });
1992
2019
  return { status: "covered", effectKey: coveredBy };
1993
2020
  }
1994
- if (holdNoticeDuringHandoff(text, key))
2021
+ if (holdNoticeDuringHandoff(text, key, [key, ...relatedKeys].some(telegram_handoff_1.isCriticalNoticeKey)))
1995
2022
  return { status: "sent", effectKey: key };
1996
2023
  await releaseHeldNotices(input.signal);
1997
2024
  const effect = await deliverButlerEffect(text, key, input.signal);
2025
+ countProactiveSend();
1998
2026
  await recordAcceptedEffects(sessionPath, [effect]);
1999
2027
  return { status: "sent", effectKey: key };
2000
2028
  });
@@ -2012,6 +2040,7 @@ function createTelegramSenseApp(options) {
2012
2040
  return;
2013
2041
  await releaseHeldNotices(input.signal);
2014
2042
  const effect = await deliverButlerEffect(text, `owner-notice:${noticeId}`, input.signal);
2043
+ countProactiveSend();
2015
2044
  const sessionPath = (0, shared_turn_1.getSenseSessionPath)(options.agentName, configuredOwnerFriendId, "telegram", configuredOwnerSessionKey, agentRoot);
2016
2045
  await recordAcceptedEffects(sessionPath, [effect]);
2017
2046
  });
@@ -2323,6 +2352,8 @@ async function startTelegramSenseApp(agentName, waitForAuthority = false) {
2323
2352
  agentName,
2324
2353
  credentials,
2325
2354
  authorityTransport,
2355
+ // The owner is in Los Angeles; a container without TZ would otherwise run quiet hours on UTC.
2356
+ proactivePacing: { timeZone: process.env.TZ || "America/Los_Angeles" },
2326
2357
  ...(await createProductionTelegramRelationshipComposition(agentName, credentials, undefined, authorityTransport)),
2327
2358
  ...(agentName === "sanctuary" ? { privilegedFailsafe: {
2328
2359
  eventRoot: (0, router_1.getExternalEventRoot)(),
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.956",
3
+ "version": "0.1.0-alpha.957",
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.956",
9
+ "version": "0.1.0-alpha.957",
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.956",
3
+ "version": "0.1.0-alpha.957",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },