@ouro.bot/cli 0.1.0-alpha.742 → 0.1.0-alpha.744

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.
Files changed (71) hide show
  1. package/changelog.json +32 -0
  2. package/deploy/unraid/Dockerfile +7 -2
  3. package/deploy/unraid/README.txt +704 -64
  4. package/deploy/unraid/migrate-sanctuary-bundle.mjs +21 -0
  5. package/deploy/unraid/ouro-events/bootstrap-spool.sh +92 -0
  6. package/deploy/unraid/ouro-events/emit-event.mjs +310 -0
  7. package/deploy/unraid/ouro-events/emit-usenet-event.sh +54 -0
  8. package/deploy/unraid/ouro-events/install-usenet-guard.sh +434 -0
  9. package/deploy/unraid/ouro-events/usenet-health.sh +178 -0
  10. package/deploy/unraid/sanctuary-acceptance-contract.json +4 -3
  11. package/deploy/unraid/sanctuary-unit16-host-broker.mjs +5 -1
  12. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +2 -2
  13. package/deploy/unraid/sanctuary.ouro/habits/sanctuary-health.md +1 -1
  14. package/deploy/unraid/sanctuary.ouro/psyche/ASPIRATIONS.md +3 -1
  15. package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +3 -1
  16. package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +4 -0
  17. package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +6 -2
  18. package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +4 -2
  19. package/deploy/unraid/sanctuary.ouro/state/policy/steward.json +7 -0
  20. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +19 -17
  21. package/deploy/unraid/sanctuary.xml +13 -10
  22. package/dist/arc/cares.js +171 -37
  23. package/dist/arc/obligations.js +38 -0
  24. package/dist/heart/awaiting/await-alert.js +4 -2
  25. package/dist/heart/awaiting/await-expiry.js +39 -4
  26. package/dist/heart/awaiting/await-parser.js +8 -0
  27. package/dist/heart/core.js +37 -7
  28. package/dist/heart/cross-chat-delivery.js +1 -0
  29. package/dist/heart/daemon/container-spec-auditor-main.js +11 -7
  30. package/dist/heart/daemon/container-spec-auditor.js +38 -11
  31. package/dist/heart/daemon/daemon-entry.js +19 -5
  32. package/dist/heart/daemon/daemon.js +154 -22
  33. package/dist/heart/daemon/sanctuary-acceptance-adapter.js +126 -42
  34. package/dist/heart/daemon/sanctuary-acceptance-harness.js +26 -29
  35. package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +42 -51
  36. package/dist/heart/daemon/sanctuary-bundle-migration.js +398 -0
  37. package/dist/heart/external-events/router.js +1025 -43
  38. package/dist/heart/session-events.js +23 -5
  39. package/dist/heart/steward-policy.js +302 -0
  40. package/dist/heart/tool-approval.js +3 -1
  41. package/dist/heart/turn-context.js +1 -0
  42. package/dist/mind/context.js +8 -3
  43. package/dist/mind/pending.js +25 -0
  44. package/dist/mind/prompt.js +49 -1
  45. package/dist/repertoire/guardrails.js +2 -1
  46. package/dist/repertoire/relationship-authorization.js +194 -0
  47. package/dist/repertoire/tools-awaiting.js +229 -26
  48. package/dist/repertoire/tools-base.js +15 -0
  49. package/dist/repertoire/tools-continuity.js +259 -4
  50. package/dist/repertoire/tools-notes.js +29 -3
  51. package/dist/repertoire/tools-session.js +219 -4
  52. package/dist/repertoire/tools-steward-policy.js +88 -0
  53. package/dist/repertoire/tools-telegram-contacts.js +37 -0
  54. package/dist/repertoire/tools-unraid.js +113 -6
  55. package/dist/repertoire/tools.js +104 -9
  56. package/dist/repertoire/unraid-restart.js +86 -4
  57. package/dist/senses/private-runtime-worker.js +36 -3
  58. package/dist/senses/private-runtime.js +310 -63
  59. package/dist/senses/sanctuary-health-acceptance-probe.js +8 -6
  60. package/dist/senses/sanctuary-health-runner.js +47 -103
  61. package/dist/senses/sanctuary-health.js +53 -105
  62. package/dist/senses/sanctuary-runtime.js +36 -2
  63. package/dist/senses/sanctuary-sab.js +80 -0
  64. package/dist/senses/shared-turn.js +64 -9
  65. package/dist/senses/telegram-admission.js +684 -0
  66. package/dist/senses/telegram-approval-runtime.js +46 -6
  67. package/dist/senses/telegram-client.js +212 -66
  68. package/dist/senses/telegram-effect-adapter.js +787 -0
  69. package/dist/senses/telegram.js +728 -48
  70. package/npm-shrinkwrap.json +6 -6
  71. package/package.json +2 -2
@@ -33,75 +33,40 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.runSanctuaryHealthPrivateTurn = runSanctuaryHealthPrivateTurn;
37
36
  exports.runSanctuaryHealthHabit = runSanctuaryHealthHabit;
38
37
  const path = __importStar(require("node:path"));
38
+ const router_1 = require("../heart/external-events/router");
39
39
  const identity_1 = require("../heart/identity");
40
- const core_1 = require("../heart/core");
41
- const autonomy_budget_1 = require("../heart/autonomy-budget");
40
+ const machine_identity_1 = require("../heart/machine-identity");
41
+ const runtime_credentials_1 = require("../heart/runtime-credentials");
42
42
  const runtime_1 = require("../nerves/runtime");
43
- const tools_1 = require("../repertoire/tools");
44
- const telegram_client_1 = require("./telegram-client");
45
43
  const sanctuary_health_1 = require("./sanctuary-health");
46
44
  const sanctuary_runtime_1 = require("./sanctuary-runtime");
47
- const telegram_1 = require("./telegram");
48
- const machine_identity_1 = require("../heart/machine-identity");
49
- const runtime_credentials_1 = require("../heart/runtime-credentials");
50
- function privateTurnCallbacks(onProviderInvocation) {
51
- return {
52
- onModelStart() { onProviderInvocation?.(); }, onModelStreamStart() { }, onTextChunk() { }, onReasoningChunk() { },
53
- onToolStart() { }, onToolEnd() { }, onError() { }, onClearText() { },
54
- };
55
- }
56
- async function runSanctuaryHealthPrivateTurn(input) {
57
- (0, identity_1.setAgentName)(input.agentName);
58
- const definition = (0, tools_1.resolveToolDefinition)("send_message");
59
- if (!definition)
60
- throw new Error("canonical send_message definition is unavailable");
61
- const agentRoot = (0, identity_1.getAgentRoot)(input.agentName);
62
- const budgetTarget = { eventId: input.eventId, payloadStored: false };
63
- const budget = (0, autonomy_budget_1.reserveAutonomyBudget)(agentRoot, {
64
- agent: input.agentName,
65
- triggerType: "habit",
66
- sourceKind: "private-runtime",
67
- senseOrHabit: "sanctuary-health",
68
- target: budgetTarget,
69
- idempotencyKey: `sanctuary-health:${input.eventId}`,
70
- }, (0, autonomy_budget_1.resolveAutonomyBudgetPolicy)(agentRoot, input.agentName));
71
- if (!budget.allowed && budget.status !== "duplicate")
72
- throw new Error(`Sanctuary health private turn blocked: ${budget.reason}`);
73
- let delivered = false;
74
- const result = await (0, core_1.runAgent)([
75
- {
76
- role: "user",
77
- content: [
78
- "A durable Sanctuary health event is pending.",
79
- `Event id: ${input.eventId}`,
80
- `Deterministic payload: ${input.payload}`,
81
- "Summarize it briefly for Ari. Call send_message exactly once with friendId=operator, channel=telegram, and the summary as content, then rest.",
82
- ].join("\n"),
83
- },
84
- ], privateTurnCallbacks(input.onProviderInvocation), "inner", undefined, {
85
- toolProfile: "sanctuary-health-private",
86
- tools: [definition.tool],
87
- toolContext: { signin: async () => undefined },
88
- execTool: async (name, args) => {
89
- if (name !== "send_message")
90
- throw new Error(`Sanctuary health profile cannot execute ${name}`);
91
- if (delivered)
92
- throw new Error("Sanctuary health Telegram delivery was already attempted");
93
- if (args.friendId !== "operator" || args.channel !== "telegram" || typeof args.content !== "string" || args.content.trim().length === 0) {
94
- throw new Error("Sanctuary health send_message target or content is invalid");
95
- }
96
- await input.deliver(args.content);
97
- delivered = true;
98
- return "delivered";
99
- },
100
- });
101
- if (result.outcome === "errored") {
102
- throw result.error ?? new Error("Sanctuary health private turn failed");
103
- }
104
- return { delivered };
45
+ function evidenceInputs(agentName, result) {
46
+ const revision = result.observationRevision;
47
+ const current = result.incidents.map((incident) => ({
48
+ agent: agentName,
49
+ source: "sanctuary-health",
50
+ eventType: "health.observed",
51
+ eventId: incident.id,
52
+ observationRevision: incident.observationRevision ?? revision,
53
+ transition: incident.transition ?? result.transition ?? "changed",
54
+ summary: incident.summary,
55
+ evidence: [incident.summary],
56
+ priority: "high",
57
+ }));
58
+ const recovered = (result.recovered ?? []).map((incident) => ({
59
+ agent: agentName,
60
+ source: "sanctuary-health",
61
+ eventType: "health.observed",
62
+ eventId: incident.id,
63
+ observationRevision: incident.observationRevision ?? revision,
64
+ transition: "recovered",
65
+ summary: `recovered: ${incident.summary}`,
66
+ evidence: [`recovered: ${incident.summary}`],
67
+ priority: "high",
68
+ }));
69
+ return [...current, ...recovered];
105
70
  }
106
71
  async function runSanctuaryHealthHabit(agentName, options = {}) {
107
72
  if (!options.createSweep && !(0, runtime_credentials_1.readMachineRuntimeCredentialConfig)(agentName).ok) {
@@ -113,45 +78,24 @@ async function runSanctuaryHealthHabit(agentName, options = {}) {
113
78
  statePath: path.join((0, identity_1.getAgentRoot)(agentName), "state", "health", "sanctuary-health.json"),
114
79
  });
115
80
  const result = await sweep();
116
- if (!result.message || !result.deliveryId) {
117
- (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_health_habit", message: "Sanctuary native health habit completed without paid work", meta: { agentName, incidentCount: result.incidents.length, delivered: false } });
118
- return { ok: true, message: "health sweep completed with no alert", data: { incidentCount: result.incidents.length, delivered: false } };
119
- }
120
- const credentials = (options.credentials ?? telegram_1.loadTelegramSenseCredentials)(agentName);
121
- const api = (options.createApi ?? ((token) => (0, telegram_client_1.createTelegramBotApi)({ token })))(credentials.botToken);
122
- const eventId = result.deliveryId;
123
- const payload = result.message;
124
- let attempted = false;
125
- try {
126
- const deliver = async (content) => {
127
- if (attempted)
128
- throw new Error("Sanctuary health Telegram delivery was already attempted");
129
- attempted = true;
130
- await sweep.cacheDeliveryPayload?.(result.deliveryId, content);
131
- await sweep.markDeliveryAttempting?.(result.deliveryId);
132
- const messageIds = await (0, telegram_client_1.sendTelegramText)(api, credentials.authorizedChatId, content);
133
- await sweep.markDelivered?.(result.deliveryId, messageIds);
134
- };
135
- const privateResult = result.cachedMessage
136
- ? (await deliver(result.cachedMessage), { delivered: true })
137
- : await (async () => {
138
- options.acceptanceMetrics?.onPrivateTurnStart();
139
- return (options.runPrivateTurn ?? runSanctuaryHealthPrivateTurn)({
140
- agentName,
141
- eventId,
142
- payload,
143
- deliver,
144
- onProviderInvocation: options.acceptanceMetrics?.onProviderInvocation,
145
- });
146
- })();
147
- if (!privateResult.delivered && !attempted) {
148
- await deliver(payload);
149
- privateResult.delivered = true;
150
- }
151
- (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_health_habit", message: "Sanctuary native health habit completed", meta: { agentName, incidentCount: result.incidents.length, delivered: privateResult.delivered } });
152
- return { ok: true, message: privateResult.delivered ? "health sweep completed and delivered" : "health event remains pending", data: { incidentCount: result.incidents.length, delivered: privateResult.delivered } };
153
- }
154
- finally {
155
- api.stop();
81
+ const inputs = evidenceInputs(agentName, result);
82
+ if (inputs.length === 0) {
83
+ (0, runtime_1.emitNervesEvent)({
84
+ component: "senses",
85
+ event: "senses.sanctuary_health_habit",
86
+ message: "Sanctuary health evidence submitted",
87
+ meta: { agentName, incidentCount: 0, submitted: 0, wakesRequested: 0 },
88
+ });
89
+ return { ok: true, message: "health evidence submitted", data: { incidentCount: 0, submitted: 0, wakesRequested: 0 } };
156
90
  }
91
+ const submit = options.submitEvidence ?? ((input) => (0, router_1.recordExternalEvent)(input));
92
+ const receipts = await Promise.all(inputs.map((input) => submit(input)));
93
+ const wakesRequested = receipts.some((receipt) => receipt.shouldWake) ? 1 : 0;
94
+ (0, runtime_1.emitNervesEvent)({
95
+ component: "senses",
96
+ event: "senses.sanctuary_health_habit",
97
+ message: "Sanctuary health evidence submitted",
98
+ meta: { agentName, incidentCount: result.incidents.length, submitted: inputs.length, wakesRequested },
99
+ });
100
+ return { ok: true, message: "health evidence submitted", data: { incidentCount: result.incidents.length, submitted: inputs.length, wakesRequested } };
157
101
  }
@@ -33,7 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.SANCTUARY_PUBLIC_ENDPOINTS = void 0;
36
37
  exports.withSanctuaryHealthStateLease = withSanctuaryHealthStateLease;
38
+ exports.readSanctuaryHealthState = readSanctuaryHealthState;
37
39
  exports.probeSanctuaryEndpoint = probeSanctuaryEndpoint;
38
40
  exports.createSanctuaryHealthSweep = createSanctuaryHealthSweep;
39
41
  const fs = __importStar(require("node:fs"));
@@ -41,7 +43,7 @@ const path = __importStar(require("node:path"));
41
43
  const node_crypto_1 = require("node:crypto");
42
44
  const runtime_1 = require("../nerves/runtime");
43
45
  const sanctuary_acceptance_marker_1 = require("../heart/daemon/sanctuary-acceptance-marker");
44
- const ENDPOINTS = [
46
+ exports.SANCTUARY_PUBLIC_ENDPOINTS = [
45
47
  "https://media.mendelow.cloud/",
46
48
  "https://books.mendelow.cloud/",
47
49
  "https://requests.mendelow.cloud/",
@@ -51,24 +53,23 @@ const REQUIRED_CONTAINERS = new Set(["calibre", "calibre-web", "Cloudflare-DDNS"
51
53
  const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
52
54
  const MAX_HEALTH_STATE_BYTES = 4 * 1024 * 1024;
53
55
  const MAX_HEALTH_TEXT_BYTES = 50_000;
54
- function canonicalIsoTimestamp(value) {
55
- if (typeof value !== "string" || value.length > 30)
56
- return false;
57
- const parsed = Date.parse(value);
58
- return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
59
- }
60
- function boundedHealthText(value) {
61
- if (Buffer.byteLength(value) <= MAX_HEALTH_TEXT_BYTES)
56
+ const MAX_HEALTH_INCIDENT_BYTES = 4_096;
57
+ function boundedIncidentText(value) {
58
+ if (Buffer.byteLength(value) <= MAX_HEALTH_INCIDENT_BYTES)
62
59
  return value;
63
- const ellipsis = "…";
64
- const contentByteLimit = MAX_HEALTH_TEXT_BYTES - Buffer.byteLength(ellipsis);
65
60
  let output = "";
66
61
  for (const character of value) {
67
- if (Buffer.byteLength(output) + Buffer.byteLength(character) > contentByteLimit)
62
+ if (Buffer.byteLength(output) + Buffer.byteLength(character) > MAX_HEALTH_INCIDENT_BYTES - 3)
68
63
  break;
69
64
  output += character;
70
65
  }
71
- return `${output}${ellipsis}`;
66
+ return `${output}…`;
67
+ }
68
+ function canonicalIsoTimestamp(value) {
69
+ if (typeof value !== "string" || value.length > 30)
70
+ return false;
71
+ const parsed = Date.parse(value);
72
+ return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
72
73
  }
73
74
  const healthLockTails = new Map();
74
75
  const healthLeaseBrand = Symbol("sanctuary-health-state-lease");
@@ -234,6 +235,10 @@ function load(filePath) {
234
235
  throw new Error("Sanctuary health state is corrupt", { cause: error });
235
236
  }
236
237
  }
238
+ /** Read-only projection for status/orientation surfaces; mutation remains lease-owned by the sweep. */
239
+ function readSanctuaryHealthState(filePath) {
240
+ return load(filePath);
241
+ }
237
242
  function save(filePath, state) {
238
243
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
239
244
  const temporary = `${filePath}.tmp-${process.pid}-${(0, node_crypto_1.randomUUID)()}`;
@@ -294,30 +299,9 @@ function createSanctuaryHealthSweep(options) {
294
299
  const startedAt = now().toISOString();
295
300
  (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_health_start", message: "Sanctuary deterministic health sweep started", meta: { statePath: options.statePath, ...acceptanceEventMeta() } });
296
301
  const pendingState = load(options.statePath);
297
- if (pendingState.outbox?.status === "pending") {
298
- (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_health_end", message: "Sanctuary health delivery recovered from durable outbox", meta: { incidentCount: Object.keys(pendingState.incidents).length, deliveryId: pendingState.outbox.id, deliveryStatus: pendingState.outbox.status, ...acceptanceEventMeta() } });
299
- return {
300
- message: pendingState.outbox.message,
301
- incidents: Object.values(pendingState.incidents),
302
- deliveryId: pendingState.outbox.id,
303
- ...(pendingState.outbox.summarizedMessage ? { cachedMessage: pendingState.outbox.summarizedMessage } : {}),
304
- };
305
- }
306
- if (pendingState.outbox?.status === "attempting" && pendingState.outbox.summarizedMessage) {
307
- pendingState.outbox.status = "pending";
308
- pendingState.updatedAt = now().toISOString();
309
- save(options.statePath, pendingState);
310
- (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_health_end", message: "Sanctuary health delivery retry recovered from durable outbox", meta: { incidentCount: Object.keys(pendingState.incidents).length, deliveryId: pendingState.outbox.id, deliveryStatus: pendingState.outbox.status, ...acceptanceEventMeta() } });
311
- return {
312
- message: pendingState.outbox.message,
313
- incidents: Object.values(pendingState.incidents),
314
- deliveryId: pendingState.outbox.id,
315
- cachedMessage: pendingState.outbox.summarizedMessage,
316
- };
317
- }
318
- if (pendingState.outbox?.status === "attempting") {
319
- pendingState.indeterminateDeliveries.push(pendingState.outbox);
302
+ if (pendingState.outbox) {
320
303
  pendingState.outbox = null;
304
+ pendingState.indeterminateDeliveries = [];
321
305
  pendingState.updatedAt = now().toISOString();
322
306
  save(options.statePath, pendingState);
323
307
  }
@@ -326,42 +310,49 @@ function createSanctuaryHealthSweep(options) {
326
310
  throw new Error("Sanctuary health runtime is unavailable");
327
311
  const [containersResult, storageResult, disksResult, notificationsResult, endpoints] = await Promise.all([
328
312
  runtime.listContainers(), runtime.getStorage(), runtime.getDisks(), runtime.getNotifications(),
329
- Promise.all(ENDPOINTS.map((url) => probeSanctuaryEndpoint(url, fetchImpl))),
313
+ Promise.all(exports.SANCTUARY_PUBLIC_ENDPOINTS.map((url) => probeSanctuaryEndpoint(url, fetchImpl))),
330
314
  ]);
331
315
  const incidents = new Map();
332
- const add = (id, summary) => incidents.set(id, { id, summary });
316
+ const add = (id, rawSummary) => {
317
+ const summary = boundedIncidentText(rawSummary);
318
+ incidents.set(id, {
319
+ id,
320
+ summary,
321
+ observationRevision: (0, node_crypto_1.createHash)("sha256").update(`${id}\0${summary}`).digest("hex"),
322
+ });
323
+ };
333
324
  const containers = record(containersResult)?.ok ? record(record(containersResult)?.data)?.containers : null;
334
325
  if (!Array.isArray(containers))
335
326
  add("containers:unavailable", "container status is unavailable");
336
327
  else
337
328
  for (const container of containers) {
338
329
  if ((container.autostart === true || REQUIRED_CONTAINERS.has(container.name)) && container.state !== "running")
339
- add(`container:${container.id}:${container.state}:${container.exitCode ?? "none"}`, `${container.name} is ${container.state}`);
330
+ add(`container:${container.id}:availability`, `${container.name} is ${container.state}`);
340
331
  if (container.state === "unknown" || container.degraded === true)
341
- add(`container:${container.id}:degraded`, `${container.name} status is degraded`);
332
+ add(`container:${container.id}:health`, `${container.name} status is degraded`);
342
333
  }
343
334
  const storage = record(storageResult)?.ok ? record(record(storageResult)?.data) : null;
344
335
  const array = record(storage?.array);
345
336
  if (!array || array.degraded || typeof array.usedPercent !== "number")
346
- add("storage:array:degraded", "array capacity is unavailable");
337
+ add("storage:array:capacity", "array capacity is unavailable");
347
338
  else if (array.usedPercent >= 90)
348
- add("storage:array:90", `array usage is ${array.usedPercent}%`);
339
+ add("storage:array:capacity", `array usage is ${array.usedPercent}%`);
349
340
  for (const share of Array.isArray(storage?.shares) ? storage.shares : [])
350
341
  if (share.degraded || typeof share.usedPercent !== "number")
351
- add(`storage:share:${share.name}:degraded`, `${share.name} capacity is unavailable`);
342
+ add(`storage:share:${share.name}:capacity`, `${share.name} capacity is unavailable`);
352
343
  else if (share.usedPercent >= 90)
353
- add(`storage:share:${share.name}:90`, `${share.name} usage is ${share.usedPercent}%`);
344
+ add(`storage:share:${share.name}:capacity`, `${share.name} usage is ${share.usedPercent}%`);
354
345
  const disks = record(disksResult)?.ok ? record(record(disksResult)?.data) : null;
355
346
  if (!disks)
356
347
  add("disks:unavailable", "disk health is unavailable");
357
348
  else {
358
349
  for (const disk of Array.isArray(disks.disks) ? disks.disks : []) {
359
350
  if (disk.smart !== "passed")
360
- add(`disk:${disk.id}:smart:${disk.smart}`, `${disk.name} SMART is ${disk.smart}`);
351
+ add(`disk:${disk.id}:smart`, `${disk.name} SMART is ${disk.smart}`);
361
352
  if (typeof disk.temperatureC !== "number")
362
- add(`disk:${disk.id}:temperature:unknown`, `${disk.name} temperature is unavailable`);
353
+ add(`disk:${disk.id}:temperature`, `${disk.name} temperature is unavailable`);
363
354
  else if (disk.temperatureC >= 50)
364
- add(`disk:${disk.id}:temperature:50`, `${disk.name} is ${disk.temperatureC}°C`);
355
+ add(`disk:${disk.id}:temperature`, `${disk.name} is ${disk.temperatureC}°C`);
365
356
  }
366
357
  const parity = record(disks.parity);
367
358
  if (!parity || parity.result !== "success" || typeof parity.ageHours !== "number" || parity.ageHours >= 45 * 24)
@@ -377,84 +368,41 @@ function createSanctuaryHealthSweep(options) {
377
368
  if (!endpoint.ok)
378
369
  add(`endpoint:${endpoint.url}`, `${endpoint.url} returned ${endpoint.status || "no response"}`);
379
370
  const previous = load(options.statePath);
380
- const current = Object.fromEntries([...incidents.entries()].sort(([a], [b]) => a.localeCompare(b)));
371
+ const current = Object.fromEntries([...incidents.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([id, incident]) => {
372
+ const prior = previous.incidents[id];
373
+ const transition = !prior ? "opened" : prior.observationRevision === incident.observationRevision ? "unchanged" : "changed";
374
+ return [id, { ...incident, transition }];
375
+ }));
381
376
  const opened = Object.values(current).filter((incident) => !previous.incidents[incident.id]);
377
+ const changed = Object.values(current).filter((incident) => incident.transition === "changed");
382
378
  const recovered = Object.values(previous.incidents).filter((incident) => !current[incident.id]);
383
- const localParts = new Intl.DateTimeFormat("en-CA", { timeZone: "America/Los_Angeles", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", hourCycle: "h23" }).formatToParts(now());
384
- const part = (type) => localParts.find((item) => item.type === type)?.value ?? "";
385
- const day = `${part("year")}-${part("month")}-${part("day")}`;
386
- const digestDue = Number(part("hour")) >= 9
387
- && (Object.keys(current).length > 0 || previous.indeterminateDeliveries.length > 0)
388
- && previous.lastDigestDay !== day;
389
- const lines = [
390
- ...opened.map((incident) => `🚨 ${incident.summary}`),
391
- ...recovered.map((incident) => `✅ recovered: ${incident.summary}`),
392
- ...(digestDue && Object.keys(current).length > 0 ? [`📋 still broken: ${Object.values(current).map((incident) => incident.summary).join("; ")}`] : []),
393
- ...(digestDue && previous.indeterminateDeliveries.length > 0
394
- ? previous.indeterminateDeliveries.map((delivery) => `⚠️ prior Telegram delivery was indeterminate: ${delivery.message}`)
395
- : []),
396
- ];
397
- const message = lines.length ? boundedHealthText(lines.join("\n")) : null;
398
- const deliveryKind = digestDue && (opened.length > 0 || recovered.length > 0)
399
- ? "transition_and_digest"
400
- : digestDue ? "digest" : "transition";
401
- const delivery = message ? { id: (0, node_crypto_1.randomUUID)(), message, status: "pending", createdAt: now().toISOString(), kind: deliveryKind } : null;
402
379
  const acceptanceMeta = acceptanceEventMeta();
403
380
  const completedAt = now().toISOString();
381
+ const incidentDigest = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(current)).digest("hex");
404
382
  const sweepReceipt = {
405
383
  sweepId,
406
384
  startedAt,
407
385
  completedAt,
408
- incidentDigest: (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(current)).digest("hex"),
386
+ incidentDigest,
409
387
  opened: opened.length,
410
388
  recovered: recovered.length,
411
- digestDue,
412
- ...(delivery ? { deliveryId: delivery.id } : {}),
389
+ digestDue: false,
413
390
  ...(acceptanceMeta.scenarioHandleDigest ? { scenarioHandleDigest: acceptanceMeta.scenarioHandleDigest } : {}),
414
391
  };
415
392
  const next = {
416
393
  incidents: current,
417
- lastDigestDay: digestDue ? day : previous.lastDigestDay,
394
+ lastDigestDay: null,
418
395
  updatedAt: now().toISOString(),
419
- outbox: delivery,
420
- indeterminateDeliveries: digestDue ? [] : previous.indeterminateDeliveries,
396
+ outbox: null,
397
+ indeterminateDeliveries: [],
421
398
  deliveredReceipts: previous.deliveredReceipts,
422
399
  sweepReceipts: [...previous.sweepReceipts, sweepReceipt].slice(-500),
423
400
  };
424
401
  save(options.statePath, next);
425
- (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_health_end", message: "Sanctuary deterministic health sweep completed", meta: { incidentCount: incidents.size, opened: opened.length, recovered: recovered.length, digestDue, ...acceptanceEventMeta() } });
426
- return { message, incidents: Object.values(current), ...(delivery ? { deliveryId: delivery.id } : {}) };
402
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_health_end", message: "Sanctuary deterministic health sweep completed", meta: { incidentCount: incidents.size, opened: opened.length, recovered: recovered.length, digestDue: false, ...acceptanceEventMeta() } });
403
+ const transition = changed.length > 0 || (opened.length > 0 && recovered.length > 0) ? "changed" : opened.length > 0 ? "opened" : recovered.length > 0 ? "recovered" : "unchanged";
404
+ return { message: null, incidents: Object.values(current), recovered, observationRevision: incidentDigest, transition };
427
405
  };
428
406
  const sweep = (() => withHealthLock(options.statePath, runSweep, options.lease));
429
- sweep.markDeliveryAttempting = (deliveryId) => withHealthLock(options.statePath, () => {
430
- const state = load(options.statePath);
431
- if (!state.outbox || state.outbox.id !== deliveryId || state.outbox.status !== "pending")
432
- throw new Error(`Sanctuary health delivery ${deliveryId} is not pending`);
433
- state.outbox.status = "attempting";
434
- state.updatedAt = now().toISOString();
435
- save(options.statePath, state);
436
- }, options.lease);
437
- sweep.cacheDeliveryPayload = (deliveryId, message) => withHealthLock(options.statePath, () => {
438
- if (typeof message !== "string" || message.trim().length === 0 || Buffer.byteLength(message) > MAX_HEALTH_TEXT_BYTES)
439
- throw new Error("Sanctuary health cached delivery payload must be nonempty and bounded");
440
- const state = load(options.statePath);
441
- if (!state.outbox || state.outbox.id !== deliveryId || state.outbox.status !== "pending")
442
- throw new Error(`Sanctuary health delivery ${deliveryId} is not pending`);
443
- state.outbox.summarizedMessage = message;
444
- state.updatedAt = now().toISOString();
445
- save(options.statePath, state);
446
- }, options.lease);
447
- sweep.markDelivered = (deliveryId, messageIds) => withHealthLock(options.statePath, () => {
448
- if (!Array.isArray(messageIds) || messageIds.length < 1 || messageIds.length > 100 || !messageIds.every((id) => Number.isSafeInteger(id) && id > 0)) {
449
- throw new Error("Sanctuary health delivery receipt requires canonical Telegram message ids");
450
- }
451
- const state = load(options.statePath);
452
- if (!state.outbox || state.outbox.id !== deliveryId || state.outbox.status !== "attempting")
453
- throw new Error(`Sanctuary health delivery ${deliveryId} is not attempting`);
454
- state.deliveredReceipts = [...state.deliveredReceipts, { deliveryId, kind: state.outbox.kind, messageIds: [...messageIds], deliveredAt: now().toISOString() }].slice(-100);
455
- state.outbox = null;
456
- state.updatedAt = now().toISOString();
457
- save(options.statePath, state);
458
- }, options.lease);
459
407
  return sweep;
460
408
  }
@@ -42,11 +42,14 @@ const node_async_hooks_1 = require("node:async_hooks");
42
42
  const identity_1 = require("../heart/identity");
43
43
  const runtime_credentials_1 = require("../heart/runtime-credentials");
44
44
  const unraid_restart_1 = require("../repertoire/unraid-restart");
45
+ const steward_policy_1 = require("../heart/steward-policy");
45
46
  const unraid_client_1 = require("../repertoire/unraid-client");
46
47
  const tools_unraid_1 = require("../repertoire/tools-unraid");
47
48
  const runtime_1 = require("../nerves/runtime");
48
49
  const sanctuary_acceptance_marker_1 = require("../heart/daemon/sanctuary-acceptance-marker");
49
50
  const sanctuary_grounding_1 = require("./sanctuary-grounding");
51
+ const sanctuary_health_1 = require("./sanctuary-health");
52
+ const sanctuary_sab_1 = require("./sanctuary-sab");
50
53
  const sanctuaryToolReceipts = new node_async_hooks_1.AsyncLocalStorage();
51
54
  const sanctuaryToolGroundings = new node_async_hooks_1.AsyncLocalStorage();
52
55
  const acceptanceLedgerTails = new Map();
@@ -165,6 +168,7 @@ function createSanctuaryToolContext(agentName) {
165
168
  const endpoint = required(initial, "unraidGraphqlUrl");
166
169
  const readClient = new unraid_client_1.UnraidClient({ endpoint, apiKey: required(initial, "unraidReadApiKey") });
167
170
  const reads = (0, tools_unraid_1.createUnraidReadTools)(readClient);
171
+ const sab = (0, sanctuary_sab_1.createSanctuarySabClient)();
168
172
  const acceptanceRead = (toolName, read) => async (...args) => {
169
173
  try {
170
174
  const result = await read(...args);
@@ -187,6 +191,8 @@ function createSanctuaryToolContext(agentName) {
187
191
  },
188
192
  acceptanceScenarioHandleDigest: () => (0, sanctuary_acceptance_marker_1.readSanctuaryAcceptanceMarker)(agentName)?.scenarioHandleDigest,
189
193
  acceptanceApproval: sanctuary_acceptance_marker_1.readSanctuaryAcceptanceApproval,
194
+ reserveRoutineAction: (input) => (0, steward_policy_1.consumeRoutineActionGrant)((0, identity_1.getAgentRoot)(agentName), input),
195
+ transitionRoutineAction: (input) => (0, steward_policy_1.transitionRoutineActionReceipt)((0, identity_1.getAgentRoot)(agentName), input),
190
196
  });
191
197
  return {
192
198
  sanctuary: {
@@ -196,11 +202,39 @@ function createSanctuaryToolContext(agentName) {
196
202
  getDisks: acceptanceRead("unraid_get_disks", reads.getDisks),
197
203
  getNotifications: acceptanceRead("unraid_get_notifications", reads.getNotifications),
198
204
  getSystem: acceptanceRead("unraid_get_system", reads.getSystem),
199
- restartContainer: async (args) => {
200
- const result = await restart(args);
205
+ checkServices: acceptanceRead("unraid_check_services", async () => {
206
+ const services = await Promise.all(sanctuary_health_1.SANCTUARY_PUBLIC_ENDPOINTS.map(async (url) => ({ name: new URL(url).hostname.split(".")[0], ...await (0, sanctuary_health_1.probeSanctuaryEndpoint)(url) })));
207
+ return { ok: true, data: { observedAt: new Date().toISOString(), services, degraded: services.some((service) => !service.ok) } };
208
+ }),
209
+ getDownloadQueue: acceptanceRead("sanctuary_get_download_queue", sab.readQueue),
210
+ resumeDownloadQueue: async () => {
211
+ try {
212
+ const result = await sab.resumeQueue();
213
+ collectToolResult(result);
214
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_download_resume_receipt", message: "Sanctuary download queue resume completed", meta: { changed: result.changed, verified: result.verified, receiptDigest: result.receiptDigest } });
215
+ return { ok: true, data: result };
216
+ }
217
+ catch (error) {
218
+ (0, runtime_1.emitNervesEvent)({ level: "error", component: "senses", event: "senses.sanctuary_download_resume_error", message: "Sanctuary download queue resume failed", meta: { category: error instanceof Error ? error.name : "unknown" } });
219
+ throw error;
220
+ }
221
+ },
222
+ restartContainer: async (args, execution) => {
223
+ const result = execution ? await restart(args, execution) : await restart(args);
201
224
  collectToolResult(result);
202
225
  return result;
203
226
  },
227
+ recoverRoutineActions: () => (0, steward_policy_1.recoverRoutineActionReceipts)((0, identity_1.getAgentRoot)(agentName), {
228
+ observeTarget: async (target) => {
229
+ const observed = await reads.listContainers();
230
+ if (!observed.ok)
231
+ throw new Error(observed.error.message);
232
+ const matches = observed.data.containers.filter((container) => container.id === target.id && container.name === target.name);
233
+ if (matches.length !== 1)
234
+ return { ...target, state: "unknown" };
235
+ return { id: matches[0].id, name: matches[0].name, state: matches[0].state };
236
+ },
237
+ }),
204
238
  },
205
239
  };
206
240
  }
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createSanctuarySabClient = createSanctuarySabClient;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const node_fs_1 = require("node:fs");
6
+ const runtime_1 = require("../nerves/runtime");
7
+ const DEFAULT_INI_PATH = "/run/sanctuary/sabnzbd.ini";
8
+ const BASE_URL = "http://127.0.0.1:8090/api";
9
+ function apiKey(iniPath) {
10
+ let contents = "";
11
+ try {
12
+ contents = (0, node_fs_1.readFileSync)(iniPath, "utf8");
13
+ }
14
+ catch {
15
+ throw new Error("SAB queue verification credential is unavailable");
16
+ }
17
+ const value = contents.match(/^\s*api_key\s*=\s*(\S+)\s*$/mu)?.[1];
18
+ if (!value)
19
+ throw new Error("SAB queue verification credential is unavailable");
20
+ return value;
21
+ }
22
+ function queuedJobs(value) {
23
+ const parsed = typeof value === "string" && /^\d{1,7}$/u.test(value) ? Number(value) : value;
24
+ if (!Number.isSafeInteger(parsed) || Number(parsed) < 0 || Number(parsed) > 1_000_000)
25
+ throw new Error("SAB queue response is malformed");
26
+ return Number(parsed);
27
+ }
28
+ function createSanctuarySabClient(options = {}) {
29
+ const iniPath = options.iniPath ?? DEFAULT_INI_PATH;
30
+ const fetchImpl = options.fetch ?? fetch;
31
+ let cachedApiKey = null;
32
+ const request = async (mode) => {
33
+ cachedApiKey ??= apiKey(iniPath);
34
+ let response;
35
+ try {
36
+ response = await fetchImpl(`${BASE_URL}?mode=${mode}${mode === "queue" ? "&output=json" : ""}&apikey=${encodeURIComponent(cachedApiKey)}`, { signal: AbortSignal.timeout(15_000) });
37
+ }
38
+ catch {
39
+ throw new Error("SAB queue request failed");
40
+ }
41
+ if (!response.ok)
42
+ throw new Error("SAB queue request failed");
43
+ return response;
44
+ };
45
+ const readQueue = async () => {
46
+ const response = await request("queue");
47
+ const body = await response.json();
48
+ if (!body || typeof body !== "object" || Array.isArray(body))
49
+ throw new Error("SAB queue response is malformed");
50
+ const queue = body.queue;
51
+ if (!queue || typeof queue !== "object" || Array.isArray(queue))
52
+ throw new Error("SAB queue response is malformed");
53
+ const fields = queue;
54
+ if (typeof fields.paused !== "boolean" || (fields.status !== undefined && (typeof fields.status !== "string" || Buffer.byteLength(fields.status) > 64)))
55
+ throw new Error("SAB queue response is malformed");
56
+ const status = typeof fields.status === "string" ? fields.status : "unknown";
57
+ const jobs = fields.noofslots === undefined ? 0 : queuedJobs(fields.noofslots);
58
+ const observedAt = options.now?.() ?? new Date().toISOString();
59
+ const stateDigest = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify({ paused: fields.paused, status, queuedJobs: jobs })).digest("hex");
60
+ return { paused: fields.paused, status, queuedJobs: jobs, observedAt, stateDigest };
61
+ };
62
+ return {
63
+ readQueue,
64
+ async resumeQueue() {
65
+ const before = await readQueue();
66
+ if (!before.paused) {
67
+ const receiptDigest = (0, node_crypto_1.createHash)("sha256").update(`sabnzbd.resume\0${before.stateDigest}\0${before.stateDigest}`).digest("hex");
68
+ return { changed: false, before, after: before, verified: true, receiptDigest };
69
+ }
70
+ const response = await request("resume");
71
+ await response.body?.cancel().catch(() => undefined);
72
+ const after = await readQueue();
73
+ if (after.paused)
74
+ throw new Error("SAB queue resume could not be verified");
75
+ const receiptDigest = (0, node_crypto_1.createHash)("sha256").update(`sabnzbd.resume\0${before.stateDigest}\0${after.stateDigest}`).digest("hex");
76
+ return { changed: true, before, after, verified: true, receiptDigest };
77
+ },
78
+ };
79
+ }
80
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_sab_loaded", message: "typed Sanctuary SAB queue client loaded", meta: { operations: 2 } });