@ouro.bot/cli 0.1.0-alpha.741 → 0.1.0-alpha.743

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 (81) hide show
  1. package/changelog.json +42 -0
  2. package/deploy/unraid/Dockerfile +7 -2
  3. package/deploy/unraid/README.txt +717 -106
  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/agent.json +2 -2
  13. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +2 -2
  14. package/deploy/unraid/sanctuary.ouro/habits/sanctuary-health.md +1 -1
  15. package/deploy/unraid/sanctuary.ouro/provider-readiness.json +3 -10
  16. package/deploy/unraid/sanctuary.ouro/psyche/ASPIRATIONS.md +3 -1
  17. package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +3 -1
  18. package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +4 -0
  19. package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +6 -2
  20. package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +4 -2
  21. package/deploy/unraid/sanctuary.ouro/state/policy/steward.json +7 -0
  22. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +19 -17
  23. package/deploy/unraid/sanctuary.xml +12 -9
  24. package/dist/arc/cares.js +171 -37
  25. package/dist/arc/obligations.js +38 -0
  26. package/dist/heart/awaiting/await-alert.js +4 -2
  27. package/dist/heart/awaiting/await-expiry.js +39 -4
  28. package/dist/heart/awaiting/await-parser.js +8 -0
  29. package/dist/heart/context-loss-gauntlet.js +31 -2
  30. package/dist/heart/context-loss-sentinel.js +2 -0
  31. package/dist/heart/core.js +37 -7
  32. package/dist/heart/cross-chat-delivery.js +1 -0
  33. package/dist/heart/daemon/container-healthcheck.js +3 -1
  34. package/dist/heart/daemon/container-runtime.js +13 -0
  35. package/dist/heart/daemon/container-spec-auditor-main.js +11 -7
  36. package/dist/heart/daemon/container-spec-auditor.js +38 -11
  37. package/dist/heart/daemon/daemon-entry.js +19 -5
  38. package/dist/heart/daemon/daemon.js +154 -22
  39. package/dist/heart/daemon/sanctuary-acceptance-adapter.js +151 -87
  40. package/dist/heart/daemon/sanctuary-acceptance-harness.js +26 -29
  41. package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +42 -51
  42. package/dist/heart/daemon/sanctuary-bundle-migration.js +398 -0
  43. package/dist/heart/daemon/sanctuary-scheduler-origin.js +11 -5
  44. package/dist/heart/daemon/supercronic-supervisor.js +16 -1
  45. package/dist/heart/external-events/router.js +1025 -43
  46. package/dist/heart/provider-models.js +10 -3
  47. package/dist/heart/session-events.js +23 -5
  48. package/dist/heart/steward-policy.js +302 -0
  49. package/dist/heart/tool-approval.js +3 -1
  50. package/dist/heart/turn-context.js +1 -0
  51. package/dist/mind/context.js +8 -3
  52. package/dist/mind/pending.js +25 -0
  53. package/dist/mind/prompt.js +49 -1
  54. package/dist/repertoire/guardrails.js +2 -1
  55. package/dist/repertoire/relationship-authorization.js +194 -0
  56. package/dist/repertoire/tools-awaiting.js +229 -26
  57. package/dist/repertoire/tools-base.js +15 -0
  58. package/dist/repertoire/tools-continuity.js +259 -4
  59. package/dist/repertoire/tools-notes.js +29 -3
  60. package/dist/repertoire/tools-session.js +219 -4
  61. package/dist/repertoire/tools-steward-policy.js +88 -0
  62. package/dist/repertoire/tools-telegram-contacts.js +37 -0
  63. package/dist/repertoire/tools-unraid.js +113 -6
  64. package/dist/repertoire/tools.js +104 -9
  65. package/dist/repertoire/unraid-restart.js +86 -4
  66. package/dist/senses/private-runtime-worker.js +36 -3
  67. package/dist/senses/private-runtime.js +310 -63
  68. package/dist/senses/sanctuary-health-acceptance-probe.js +8 -6
  69. package/dist/senses/sanctuary-health-runner.js +51 -96
  70. package/dist/senses/sanctuary-health.js +54 -93
  71. package/dist/senses/sanctuary-runtime.js +36 -2
  72. package/dist/senses/sanctuary-sab.js +80 -0
  73. package/dist/senses/shared-turn.js +64 -9
  74. package/dist/senses/telegram-admission.js +684 -0
  75. package/dist/senses/telegram-approval-runtime.js +46 -6
  76. package/dist/senses/telegram-client.js +236 -72
  77. package/dist/senses/telegram-effect-adapter.js +787 -0
  78. package/dist/senses/telegram-entry.js +4 -1
  79. package/dist/senses/telegram.js +728 -48
  80. package/npm-shrinkwrap.json +6 -6
  81. package/package.json +2 -2
@@ -33,114 +33,69 @@ 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
- function privateTurnCallbacks(onProviderInvocation) {
49
- return {
50
- onModelStart() { onProviderInvocation?.(); }, onModelStreamStart() { }, onTextChunk() { }, onReasoningChunk() { },
51
- onToolStart() { }, onToolEnd() { }, onError() { }, onClearText() { },
52
- };
53
- }
54
- async function runSanctuaryHealthPrivateTurn(input) {
55
- const definition = (0, tools_1.resolveToolDefinition)("send_message");
56
- if (!definition)
57
- throw new Error("canonical send_message definition is unavailable");
58
- const agentRoot = (0, identity_1.getAgentRoot)(input.agentName);
59
- const budgetTarget = { eventId: input.eventId, payloadStored: false };
60
- const budget = (0, autonomy_budget_1.reserveAutonomyBudget)(agentRoot, {
61
- agent: input.agentName,
62
- triggerType: "habit",
63
- sourceKind: "private-runtime",
64
- senseOrHabit: "sanctuary-health",
65
- target: budgetTarget,
66
- idempotencyKey: `sanctuary-health:${input.eventId}`,
67
- }, (0, autonomy_budget_1.resolveAutonomyBudgetPolicy)(agentRoot, input.agentName));
68
- if (!budget.allowed && budget.status !== "duplicate")
69
- throw new Error(`Sanctuary health private turn blocked: ${budget.reason}`);
70
- let delivered = false;
71
- const result = await (0, core_1.runAgent)([
72
- {
73
- role: "user",
74
- content: [
75
- "A durable Sanctuary health event is pending.",
76
- `Event id: ${input.eventId}`,
77
- `Deterministic payload: ${input.payload}`,
78
- "Summarize it briefly for Ari. Call send_message exactly once with friendId=operator, channel=telegram, and the summary as content, then rest.",
79
- ].join("\n"),
80
- },
81
- ], privateTurnCallbacks(input.onProviderInvocation), "inner", undefined, {
82
- toolProfile: "sanctuary-health-private",
83
- tools: [definition.tool],
84
- toolContext: { signin: async () => undefined },
85
- execTool: async (name, args) => {
86
- if (name !== "send_message")
87
- throw new Error(`Sanctuary health profile cannot execute ${name}`);
88
- if (delivered)
89
- throw new Error("Sanctuary health Telegram delivery was already attempted");
90
- if (args.friendId !== "operator" || args.channel !== "telegram" || typeof args.content !== "string" || args.content.trim().length === 0) {
91
- throw new Error("Sanctuary health send_message target or content is invalid");
92
- }
93
- await input.deliver(args.content);
94
- delivered = true;
95
- return "delivered";
96
- },
97
- });
98
- if (result.outcome === "errored") {
99
- throw result.error ?? new Error("Sanctuary health private turn failed");
100
- }
101
- 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];
102
70
  }
103
71
  async function runSanctuaryHealthHabit(agentName, options = {}) {
72
+ if (!options.createSweep && !(0, runtime_credentials_1.readMachineRuntimeCredentialConfig)(agentName).ok) {
73
+ const machine = (0, machine_identity_1.loadOrCreateMachineIdentity)();
74
+ await (0, runtime_credentials_1.refreshMachineRuntimeCredentialConfig)(agentName, machine.machineId, { preserveCachedOnFailure: true });
75
+ }
104
76
  const sweep = options.createSweep?.(agentName) ?? (0, sanctuary_health_1.createSanctuaryHealthSweep)({
105
77
  toolContext: (0, sanctuary_runtime_1.createSanctuaryToolContext)(agentName),
106
78
  statePath: path.join((0, identity_1.getAgentRoot)(agentName), "state", "health", "sanctuary-health.json"),
107
79
  });
108
80
  const result = await sweep();
109
- if (!result.message || !result.deliveryId) {
110
- (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 } });
111
- return { ok: true, message: "health sweep completed with no alert", data: { incidentCount: result.incidents.length, delivered: false } };
112
- }
113
- const credentials = (options.credentials ?? telegram_1.loadTelegramSenseCredentials)(agentName);
114
- const api = (options.createApi ?? ((token) => (0, telegram_client_1.createTelegramBotApi)({ token })))(credentials.botToken);
115
- const eventId = result.deliveryId;
116
- const payload = result.message;
117
- let attempted = false;
118
- try {
119
- const deliver = async (content) => {
120
- if (attempted)
121
- throw new Error("Sanctuary health Telegram delivery was already attempted");
122
- attempted = true;
123
- await sweep.cacheDeliveryPayload?.(result.deliveryId, content);
124
- await sweep.markDeliveryAttempting?.(result.deliveryId);
125
- const messageIds = await (0, telegram_client_1.sendTelegramText)(api, credentials.authorizedChatId, content);
126
- await sweep.markDelivered?.(result.deliveryId, messageIds);
127
- };
128
- const privateResult = result.cachedMessage
129
- ? (await deliver(result.cachedMessage), { delivered: true })
130
- : await (async () => {
131
- options.acceptanceMetrics?.onPrivateTurnStart();
132
- return (options.runPrivateTurn ?? runSanctuaryHealthPrivateTurn)({
133
- agentName,
134
- eventId,
135
- payload,
136
- deliver,
137
- onProviderInvocation: options.acceptanceMetrics?.onProviderInvocation,
138
- });
139
- })();
140
- (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 } });
141
- return { ok: true, message: privateResult.delivered ? "health sweep completed and delivered" : "health event remains pending", data: { incidentCount: result.incidents.length, delivered: privateResult.delivered } };
142
- }
143
- finally {
144
- 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 } };
145
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 } };
146
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");
@@ -106,6 +107,7 @@ async function acquireHealthFileLease(statePath, timeoutMs) {
106
107
  const ownerPath = path.join(leasePath, "owner.json");
107
108
  const deadline = Date.now() + timeoutMs;
108
109
  const nonce = (0, node_crypto_1.createHash)("sha256").update((0, node_crypto_1.randomUUID)()).digest("hex");
110
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
109
111
  for (;;) {
110
112
  try {
111
113
  fs.mkdirSync(leasePath, { recursive: false, mode: 0o700 });
@@ -233,6 +235,10 @@ function load(filePath) {
233
235
  throw new Error("Sanctuary health state is corrupt", { cause: error });
234
236
  }
235
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
+ }
236
242
  function save(filePath, state) {
237
243
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
238
244
  const temporary = `${filePath}.tmp-${process.pid}-${(0, node_crypto_1.randomUUID)()}`;
@@ -293,18 +299,9 @@ function createSanctuaryHealthSweep(options) {
293
299
  const startedAt = now().toISOString();
294
300
  (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.sanctuary_health_start", message: "Sanctuary deterministic health sweep started", meta: { statePath: options.statePath, ...acceptanceEventMeta() } });
295
301
  const pendingState = load(options.statePath);
296
- if (pendingState.outbox?.status === "pending") {
297
- (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() } });
298
- return {
299
- message: pendingState.outbox.message,
300
- incidents: Object.values(pendingState.incidents),
301
- deliveryId: pendingState.outbox.id,
302
- ...(pendingState.outbox.summarizedMessage ? { cachedMessage: pendingState.outbox.summarizedMessage } : {}),
303
- };
304
- }
305
- if (pendingState.outbox?.status === "attempting") {
306
- pendingState.indeterminateDeliveries.push(pendingState.outbox);
302
+ if (pendingState.outbox) {
307
303
  pendingState.outbox = null;
304
+ pendingState.indeterminateDeliveries = [];
308
305
  pendingState.updatedAt = now().toISOString();
309
306
  save(options.statePath, pendingState);
310
307
  }
@@ -313,42 +310,49 @@ function createSanctuaryHealthSweep(options) {
313
310
  throw new Error("Sanctuary health runtime is unavailable");
314
311
  const [containersResult, storageResult, disksResult, notificationsResult, endpoints] = await Promise.all([
315
312
  runtime.listContainers(), runtime.getStorage(), runtime.getDisks(), runtime.getNotifications(),
316
- Promise.all(ENDPOINTS.map((url) => probeSanctuaryEndpoint(url, fetchImpl))),
313
+ Promise.all(exports.SANCTUARY_PUBLIC_ENDPOINTS.map((url) => probeSanctuaryEndpoint(url, fetchImpl))),
317
314
  ]);
318
315
  const incidents = new Map();
319
- 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
+ };
320
324
  const containers = record(containersResult)?.ok ? record(record(containersResult)?.data)?.containers : null;
321
325
  if (!Array.isArray(containers))
322
326
  add("containers:unavailable", "container status is unavailable");
323
327
  else
324
328
  for (const container of containers) {
325
329
  if ((container.autostart === true || REQUIRED_CONTAINERS.has(container.name)) && container.state !== "running")
326
- 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}`);
327
331
  if (container.state === "unknown" || container.degraded === true)
328
- add(`container:${container.id}:degraded`, `${container.name} status is degraded`);
332
+ add(`container:${container.id}:health`, `${container.name} status is degraded`);
329
333
  }
330
334
  const storage = record(storageResult)?.ok ? record(record(storageResult)?.data) : null;
331
335
  const array = record(storage?.array);
332
336
  if (!array || array.degraded || typeof array.usedPercent !== "number")
333
- add("storage:array:degraded", "array capacity is unavailable");
337
+ add("storage:array:capacity", "array capacity is unavailable");
334
338
  else if (array.usedPercent >= 90)
335
- add("storage:array:90", `array usage is ${array.usedPercent}%`);
339
+ add("storage:array:capacity", `array usage is ${array.usedPercent}%`);
336
340
  for (const share of Array.isArray(storage?.shares) ? storage.shares : [])
337
341
  if (share.degraded || typeof share.usedPercent !== "number")
338
- add(`storage:share:${share.name}:degraded`, `${share.name} capacity is unavailable`);
342
+ add(`storage:share:${share.name}:capacity`, `${share.name} capacity is unavailable`);
339
343
  else if (share.usedPercent >= 90)
340
- 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}%`);
341
345
  const disks = record(disksResult)?.ok ? record(record(disksResult)?.data) : null;
342
346
  if (!disks)
343
347
  add("disks:unavailable", "disk health is unavailable");
344
348
  else {
345
349
  for (const disk of Array.isArray(disks.disks) ? disks.disks : []) {
346
350
  if (disk.smart !== "passed")
347
- 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}`);
348
352
  if (typeof disk.temperatureC !== "number")
349
- add(`disk:${disk.id}:temperature:unknown`, `${disk.name} temperature is unavailable`);
353
+ add(`disk:${disk.id}:temperature`, `${disk.name} temperature is unavailable`);
350
354
  else if (disk.temperatureC >= 50)
351
- add(`disk:${disk.id}:temperature:50`, `${disk.name} is ${disk.temperatureC}°C`);
355
+ add(`disk:${disk.id}:temperature`, `${disk.name} is ${disk.temperatureC}°C`);
352
356
  }
353
357
  const parity = record(disks.parity);
354
358
  if (!parity || parity.result !== "success" || typeof parity.ageHours !== "number" || parity.ageHours >= 45 * 24)
@@ -364,84 +368,41 @@ function createSanctuaryHealthSweep(options) {
364
368
  if (!endpoint.ok)
365
369
  add(`endpoint:${endpoint.url}`, `${endpoint.url} returned ${endpoint.status || "no response"}`);
366
370
  const previous = load(options.statePath);
367
- 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
+ }));
368
376
  const opened = Object.values(current).filter((incident) => !previous.incidents[incident.id]);
377
+ const changed = Object.values(current).filter((incident) => incident.transition === "changed");
369
378
  const recovered = Object.values(previous.incidents).filter((incident) => !current[incident.id]);
370
- 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());
371
- const part = (type) => localParts.find((item) => item.type === type)?.value ?? "";
372
- const day = `${part("year")}-${part("month")}-${part("day")}`;
373
- const digestDue = Number(part("hour")) >= 9
374
- && (Object.keys(current).length > 0 || previous.indeterminateDeliveries.length > 0)
375
- && previous.lastDigestDay !== day;
376
- const lines = [
377
- ...opened.map((incident) => `🚨 ${incident.summary}`),
378
- ...recovered.map((incident) => `✅ recovered: ${incident.summary}`),
379
- ...(digestDue && Object.keys(current).length > 0 ? [`📋 still broken: ${Object.values(current).map((incident) => incident.summary).join("; ")}`] : []),
380
- ...(digestDue && previous.indeterminateDeliveries.length > 0
381
- ? previous.indeterminateDeliveries.map((delivery) => `⚠️ prior Telegram delivery was indeterminate: ${delivery.message}`)
382
- : []),
383
- ];
384
- const message = lines.length ? boundedHealthText(lines.join("\n")) : null;
385
- const deliveryKind = digestDue && (opened.length > 0 || recovered.length > 0)
386
- ? "transition_and_digest"
387
- : digestDue ? "digest" : "transition";
388
- const delivery = message ? { id: (0, node_crypto_1.randomUUID)(), message, status: "pending", createdAt: now().toISOString(), kind: deliveryKind } : null;
389
379
  const acceptanceMeta = acceptanceEventMeta();
390
380
  const completedAt = now().toISOString();
381
+ const incidentDigest = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(current)).digest("hex");
391
382
  const sweepReceipt = {
392
383
  sweepId,
393
384
  startedAt,
394
385
  completedAt,
395
- incidentDigest: (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(current)).digest("hex"),
386
+ incidentDigest,
396
387
  opened: opened.length,
397
388
  recovered: recovered.length,
398
- digestDue,
399
- ...(delivery ? { deliveryId: delivery.id } : {}),
389
+ digestDue: false,
400
390
  ...(acceptanceMeta.scenarioHandleDigest ? { scenarioHandleDigest: acceptanceMeta.scenarioHandleDigest } : {}),
401
391
  };
402
392
  const next = {
403
393
  incidents: current,
404
- lastDigestDay: digestDue ? day : previous.lastDigestDay,
394
+ lastDigestDay: null,
405
395
  updatedAt: now().toISOString(),
406
- outbox: delivery,
407
- indeterminateDeliveries: digestDue ? [] : previous.indeterminateDeliveries,
396
+ outbox: null,
397
+ indeterminateDeliveries: [],
408
398
  deliveredReceipts: previous.deliveredReceipts,
409
399
  sweepReceipts: [...previous.sweepReceipts, sweepReceipt].slice(-500),
410
400
  };
411
401
  save(options.statePath, next);
412
- (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() } });
413
- 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 };
414
405
  };
415
406
  const sweep = (() => withHealthLock(options.statePath, runSweep, options.lease));
416
- sweep.markDeliveryAttempting = (deliveryId) => withHealthLock(options.statePath, () => {
417
- const state = load(options.statePath);
418
- if (!state.outbox || state.outbox.id !== deliveryId || state.outbox.status !== "pending")
419
- throw new Error(`Sanctuary health delivery ${deliveryId} is not pending`);
420
- state.outbox.status = "attempting";
421
- state.updatedAt = now().toISOString();
422
- save(options.statePath, state);
423
- }, options.lease);
424
- sweep.cacheDeliveryPayload = (deliveryId, message) => withHealthLock(options.statePath, () => {
425
- if (typeof message !== "string" || message.trim().length === 0 || Buffer.byteLength(message) > MAX_HEALTH_TEXT_BYTES)
426
- throw new Error("Sanctuary health cached delivery payload must be nonempty and bounded");
427
- const state = load(options.statePath);
428
- if (!state.outbox || state.outbox.id !== deliveryId || state.outbox.status !== "pending")
429
- throw new Error(`Sanctuary health delivery ${deliveryId} is not pending`);
430
- state.outbox.summarizedMessage = message;
431
- state.updatedAt = now().toISOString();
432
- save(options.statePath, state);
433
- }, options.lease);
434
- sweep.markDelivered = (deliveryId, messageIds) => withHealthLock(options.statePath, () => {
435
- if (!Array.isArray(messageIds) || messageIds.length < 1 || messageIds.length > 100 || !messageIds.every((id) => Number.isSafeInteger(id) && id > 0)) {
436
- throw new Error("Sanctuary health delivery receipt requires canonical Telegram message ids");
437
- }
438
- const state = load(options.statePath);
439
- if (!state.outbox || state.outbox.id !== deliveryId || state.outbox.status !== "attempting")
440
- throw new Error(`Sanctuary health delivery ${deliveryId} is not attempting`);
441
- state.deliveredReceipts = [...state.deliveredReceipts, { deliveryId, kind: state.outbox.kind, messageIds: [...messageIds], deliveredAt: now().toISOString() }].slice(-100);
442
- state.outbox = null;
443
- state.updatedAt = now().toISOString();
444
- save(options.statePath, state);
445
- }, options.lease);
446
407
  return sweep;
447
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 } });