@ouro.bot/cli 0.1.0-alpha.722 → 0.1.0-alpha.724
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 +16 -0
- package/dist/heart/daemon/cli-exec.js +60 -1
- package/dist/heart/daemon/doctor.js +150 -22
- package/dist/heart/daemon/freshness.js +41 -0
- package/dist/heart/orientation-frame.js +82 -11
- package/dist/senses/bluebubbles/client.js +49 -0
- package/dist/senses/bluebubbles/index.js +22 -17
- package/dist/senses/bluebubbles/model.js +74 -8
- package/dist/senses/mail.js +129 -0
- package/package.json +1 -1
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.724",
|
|
6
|
+
"changes": [
|
|
7
|
+
"mail sense records key coverage each scan cycle and raises a distinct nerves alarm when the registry declares a mail key the vault has no private half for",
|
|
8
|
+
"ouro doctor reports mail.key_coverage: covered passes, absent fails, and an unverifiable read warns rather than reading as key loss",
|
|
9
|
+
"hosted mail setup asserts key ids against the public registry as well as the ensure response, so an omitted sourceGrant can no longer hide a missing private key"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"version": "0.1.0-alpha.723",
|
|
14
|
+
"changes": [
|
|
15
|
+
"Anchor iMessage reactions to the message they point at (tapback name, add vs remove, target excerpt and authorship) instead of a bare \"reacted with love\" stub.",
|
|
16
|
+
"Stop the orientation correction hold from blocking explicitly requested actions: reactions never arm it, a correction word inside a long self-contained instruction with nothing to disambiguate no longer arms it, and an armed hold now names its trigger, states what clears it, and emits orientation.correction_hold_armed.",
|
|
17
|
+
"ouro doctor mail-ingest liveness is now Mailroom-mode aware: on the hosted Azure Blob store it measures the hosted reader's local mirror (state/mail-search) instead of the frozen local messages/ directory, and reports an explicit unverified state when no local signal exists, so a hosted agent receiving mail today no longer fails with a fabricated multi-week outage."
|
|
18
|
+
]
|
|
19
|
+
},
|
|
4
20
|
{
|
|
5
21
|
"version": "0.1.0-alpha.722",
|
|
6
22
|
"changes": [
|
|
@@ -3707,6 +3707,57 @@ function hostedMissingMailKeys(body, keys) {
|
|
|
3707
3707
|
rotateSourceGrant: missingSourceGrant,
|
|
3708
3708
|
};
|
|
3709
3709
|
}
|
|
3710
|
+
/**
|
|
3711
|
+
* Key ids the public registry declares for this agent, or `[]` when the
|
|
3712
|
+
* registry cannot be consulted.
|
|
3713
|
+
*
|
|
3714
|
+
* `requiredHostedKeyIds` derives the asserted set from the ensure response
|
|
3715
|
+
* alone, so anything the response omits is never asserted — and the server
|
|
3716
|
+
* omits `sourceGrant` unless a source alias is in play. That is how the
|
|
3717
|
+
* 2026-07-24 gap passed: the registry declared a source-grant key, the vault
|
|
3718
|
+
* never received its private half, and setup reported success. The registry is
|
|
3719
|
+
* the authority on what mail is currently being encrypted to, so it is
|
|
3720
|
+
* consulted too.
|
|
3721
|
+
*
|
|
3722
|
+
* Never throws. An unreachable registry degrades to the response-only
|
|
3723
|
+
* assertion, because refusing a setup that previously succeeded — on a machine
|
|
3724
|
+
* whose Azure credentials happen to be unavailable — would be a worse failure
|
|
3725
|
+
* than the one this guards against.
|
|
3726
|
+
*
|
|
3727
|
+
* The agent match is case-insensitive: the harness sends `agentId: agent`
|
|
3728
|
+
* verbatim in the ensure payload while registry records can carry the
|
|
3729
|
+
* lowercased form.
|
|
3730
|
+
*/
|
|
3731
|
+
async function hostedRegistryDeclaredKeyIds(input) {
|
|
3732
|
+
try {
|
|
3733
|
+
const publicRegistry = isPlainRecord(input.body.publicRegistry) ? input.body.publicRegistry : {};
|
|
3734
|
+
const managedIdentityClientId = stringField(input.existingMailroom ?? {}, "azureManagedIdentityClientId");
|
|
3735
|
+
const registry = await input.readRegistry({
|
|
3736
|
+
mailboxAddress: "",
|
|
3737
|
+
privateKeys: {},
|
|
3738
|
+
registryAzureAccountUrl: stringField(publicRegistry, "azureAccountUrl"),
|
|
3739
|
+
registryContainer: stringField(publicRegistry, "container"),
|
|
3740
|
+
registryBlob: stringField(publicRegistry, "blob"),
|
|
3741
|
+
...(managedIdentityClientId ? { azureManagedIdentityClientId: managedIdentityClientId } : {}),
|
|
3742
|
+
});
|
|
3743
|
+
const wanted = input.agent.toLowerCase();
|
|
3744
|
+
const isThisAgent = (agentId) => agentId.toLowerCase() === wanted;
|
|
3745
|
+
return [
|
|
3746
|
+
...registry.mailboxes.filter((mailbox) => isThisAgent(mailbox.agentId)).map((mailbox) => mailbox.keyId),
|
|
3747
|
+
...registry.sourceGrants.filter((grant) => isThisAgent(grant.agentId) && grant.enabled).map((grant) => grant.keyId),
|
|
3748
|
+
].filter((keyId) => keyId.trim().length > 0);
|
|
3749
|
+
}
|
|
3750
|
+
catch (error) {
|
|
3751
|
+
(0, runtime_1.emitNervesEvent)({
|
|
3752
|
+
level: "warn",
|
|
3753
|
+
component: "cli",
|
|
3754
|
+
event: "cli.hosted_mail_registry_assertion_skipped",
|
|
3755
|
+
message: "hosted mail key assertion could not consult the public registry",
|
|
3756
|
+
meta: { agent: input.agent, error: error instanceof Error ? error.message : String(error) },
|
|
3757
|
+
});
|
|
3758
|
+
return [];
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3710
3761
|
function assertHostedPrivateKeys(input) {
|
|
3711
3762
|
for (const keyId of input.requiredKeyIds) {
|
|
3712
3763
|
if (input.keys[keyId])
|
|
@@ -3811,10 +3862,18 @@ async function ensureAgentMailroom(agent, input, deps, progressLabel) {
|
|
|
3811
3862
|
generatedPrivateKeys = responsePrivateKeys(body.generatedPrivateKeys);
|
|
3812
3863
|
Object.assign(privateKeys, generatedPrivateKeys);
|
|
3813
3864
|
}
|
|
3865
|
+
const registryKeyIds = await hostedRegistryDeclaredKeyIds({
|
|
3866
|
+
agent,
|
|
3867
|
+
body,
|
|
3868
|
+
existingMailroom,
|
|
3869
|
+
readRegistry: deps.readMailroomRegistry ?? reader_1.readMailroomRegistry,
|
|
3870
|
+
});
|
|
3814
3871
|
assertHostedPrivateKeys({
|
|
3815
3872
|
agent,
|
|
3816
3873
|
keys: privateKeys,
|
|
3817
|
-
|
|
3874
|
+
// Union, not replacement: the response still names keys it just minted,
|
|
3875
|
+
// which a registry read taken before the write would not yet show.
|
|
3876
|
+
requiredKeyIds: [...new Set([...requiredHostedKeyIds(body), ...registryKeyIds])],
|
|
3818
3877
|
});
|
|
3819
3878
|
const publicRegistry = requiredResponseRecord(body.publicRegistry, "publicRegistry");
|
|
3820
3879
|
const blobStore = requiredResponseRecord(body.blobStore, "blobStore");
|
|
@@ -99,6 +99,10 @@ function textField(record, key) {
|
|
|
99
99
|
const value = record?.[key];
|
|
100
100
|
return typeof value === "string" ? value.trim() : "";
|
|
101
101
|
}
|
|
102
|
+
function stringArrayField(record, key) {
|
|
103
|
+
const value = record?.[key];
|
|
104
|
+
return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
105
|
+
}
|
|
102
106
|
function numberField(record, key, fallback) {
|
|
103
107
|
const value = record?.[key];
|
|
104
108
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
@@ -271,6 +275,59 @@ function checkAgents(deps) {
|
|
|
271
275
|
}
|
|
272
276
|
return { name: "Agents", checks };
|
|
273
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Mail key coverage, as recorded by the mail sense.
|
|
280
|
+
*
|
|
281
|
+
* Doctor reads the sense's own per-cycle verdict rather than re-deriving it:
|
|
282
|
+
* a health check must need neither network access nor Azure credentials, and
|
|
283
|
+
* the registry that declares the key ids lives in a Blob container in hosted
|
|
284
|
+
* mode. The mail sense already fetches it every cycle, so its record is both
|
|
285
|
+
* the cheapest and the only honest source here.
|
|
286
|
+
*
|
|
287
|
+
* Absent is a `fail` because mail encrypted to a key with no private half is
|
|
288
|
+
* permanently unreadable — unlike an API key, ciphertext cannot be reissued.
|
|
289
|
+
* Anything the sense could not verify is a `warn`, never a `fail`: a locked or
|
|
290
|
+
* unavailable vault must not read as key loss.
|
|
291
|
+
*/
|
|
292
|
+
function mailKeyCoverageCheck(deps, agentDir) {
|
|
293
|
+
const id = "mail.key_coverage";
|
|
294
|
+
const label = `${agentDir} mail key coverage`;
|
|
295
|
+
const statePath = `${deps.bundlesRoot}/${agentDir}/state/senses/mail/runtime.json`;
|
|
296
|
+
const unverified = (detail) => ({ id, label, status: "warn", detail });
|
|
297
|
+
if (!deps.existsSync(statePath)) {
|
|
298
|
+
return unverified(`no mail sense runtime state at ${statePath} — coverage is unverified until the mail sense runs a scan`);
|
|
299
|
+
}
|
|
300
|
+
let coverage;
|
|
301
|
+
try {
|
|
302
|
+
coverage = asRecord(JSON.parse(deps.readFileSync(statePath)).keyCoverage) ?? {};
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return unverified(`mail sense runtime state at ${statePath} could not be read`);
|
|
306
|
+
}
|
|
307
|
+
const declaredKeyIds = stringArrayField(coverage, "declaredKeyIds");
|
|
308
|
+
const absentKeyIds = stringArrayField(coverage, "absentKeyIds");
|
|
309
|
+
const checkedAt = textField(coverage, "checkedAt");
|
|
310
|
+
const provenance = checkedAt ? ` (recorded ${checkedAt})` : "";
|
|
311
|
+
if (coverage.status === "covered") {
|
|
312
|
+
return {
|
|
313
|
+
id,
|
|
314
|
+
label,
|
|
315
|
+
status: "pass",
|
|
316
|
+
detail: declaredKeyIds.length === 1
|
|
317
|
+
? `the 1 registry-declared mail key has a private half in the vault${provenance}`
|
|
318
|
+
: `all ${declaredKeyIds.length} registry-declared mail keys have a private half in the vault${provenance}`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (coverage.status === "absent") {
|
|
322
|
+
return {
|
|
323
|
+
id,
|
|
324
|
+
label,
|
|
325
|
+
status: "fail",
|
|
326
|
+
detail: `the mailroom registry declares mail key${absentKeyIds.length === 1 ? "" : "s"} with no private half in this agent's vault — mail encrypted to ${absentKeyIds.join(", ")} cannot be decrypted and cannot be recovered${provenance}; run \`ouro connect mail --agent ${agentDir.replace(/\.ouro$/, "")}\` and, if Mail Control no longer holds the one-time key, rerun it with --rotate-missing-mail-keys to mint a replacement for future mail`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
return unverified(`mail sense could not verify key coverage${provenance}: ${textField(coverage, "reason") || "no reason recorded"}`);
|
|
330
|
+
}
|
|
274
331
|
async function checkSenses(deps) {
|
|
275
332
|
const checks = [];
|
|
276
333
|
const agents = discoverAgents(deps);
|
|
@@ -422,6 +479,10 @@ async function checkSenses(deps) {
|
|
|
422
479
|
mailAutonomyDetail(mailroom),
|
|
423
480
|
].join("; "),
|
|
424
481
|
});
|
|
482
|
+
// Config being well-formed says nothing about whether the vault can
|
|
483
|
+
// still decrypt what the registry points mail at — the 2026-07-24 gap
|
|
484
|
+
// sat behind a passing mail config check for 23 hours.
|
|
485
|
+
checks.push(mailKeyCoverageCheck(deps, agentDir));
|
|
425
486
|
}
|
|
426
487
|
}
|
|
427
488
|
}
|
|
@@ -808,7 +869,7 @@ function tripStoresDiffer(deps, durableRoot, legacyRoot) {
|
|
|
808
869
|
}
|
|
809
870
|
return false;
|
|
810
871
|
}
|
|
811
|
-
function checkMailroom(deps) {
|
|
872
|
+
async function checkMailroom(deps) {
|
|
812
873
|
const checks = [];
|
|
813
874
|
const agents = discoverAgents(deps);
|
|
814
875
|
if (agents.length === 0) {
|
|
@@ -853,7 +914,7 @@ function checkMailroom(deps) {
|
|
|
853
914
|
continue;
|
|
854
915
|
}
|
|
855
916
|
const messagesDir = `${mailroomRoot}/messages`;
|
|
856
|
-
const listing =
|
|
917
|
+
const listing = listJsonDocuments(deps, messagesDir);
|
|
857
918
|
/* v8 ignore start -- defensive: pluralization branches depend on filesystem-state fixtures not exhaustively covered @preserve */
|
|
858
919
|
checks.push({
|
|
859
920
|
label: `${agentDir} mailroom`,
|
|
@@ -861,20 +922,90 @@ function checkMailroom(deps) {
|
|
|
861
922
|
detail: `${mailboxes.length} mailbox${mailboxes.length === 1 ? "" : "es"}, ${sourceGrants.length} source grant${sourceGrants.length === 1 ? "" : "s"}, ${listing.count} message${listing.count === 1 ? "" : "s"} stored (cumulative — not a liveness signal)`,
|
|
862
923
|
});
|
|
863
924
|
/* v8 ignore stop */
|
|
864
|
-
checks.push(mailIngestLivenessCheck(deps, agentDir, mailroomRoot, messagesDir, listing));
|
|
925
|
+
checks.push(mailIngestLivenessCheck(deps, agentDir, mailroomRoot, messagesDir, listing, await resolveMailStore(agentDir)));
|
|
865
926
|
}
|
|
866
927
|
return { name: "Mailroom", checks };
|
|
867
928
|
}
|
|
868
|
-
function
|
|
869
|
-
if (!deps.existsSync(
|
|
929
|
+
function listJsonDocuments(deps, dir) {
|
|
930
|
+
if (!deps.existsSync(dir))
|
|
870
931
|
return { count: 0, readable: true };
|
|
871
932
|
try {
|
|
872
|
-
return { count: deps.readdirSync(
|
|
933
|
+
return { count: deps.readdirSync(dir).filter((name) => name.endsWith(".json")).length, readable: true };
|
|
873
934
|
}
|
|
874
935
|
catch {
|
|
875
936
|
return { count: 0, readable: false };
|
|
876
937
|
}
|
|
877
938
|
}
|
|
939
|
+
async function resolveMailStore(agentDir) {
|
|
940
|
+
const agentName = agentDir.replace(/\.ouro$/, "");
|
|
941
|
+
// An unreadable vault means the mode is unknown, so this falls back to the
|
|
942
|
+
// reader's own default — the local file store. `mail config` in checkSenses
|
|
943
|
+
// is where an unavailable runtime config is reported, and it fails hard.
|
|
944
|
+
const runtimeConfig = await (0, runtime_credentials_1.refreshRuntimeCredentialConfig)(agentName, { preserveCachedOnFailure: true });
|
|
945
|
+
if (!runtimeConfig.ok)
|
|
946
|
+
return { hosted: false };
|
|
947
|
+
const mailroom = asRecord(runtimeConfig.config.mailroom);
|
|
948
|
+
const azureAccountUrl = textField(mailroom, "azureAccountUrl");
|
|
949
|
+
if (!azureAccountUrl)
|
|
950
|
+
return { hosted: false };
|
|
951
|
+
const azureContainer = textField(mailroom, "azureContainer") || "mailroom";
|
|
952
|
+
return { hosted: true, label: `hosted azure-blob ${azureAccountUrl}/${azureContainer}` };
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Local-file Mailroom liveness signal.
|
|
956
|
+
*
|
|
957
|
+
* The `messages/` directory mtime is both the cheapest and the most correct
|
|
958
|
+
* option. Cheapest: one stat regardless of store size, and the production store
|
|
959
|
+
* holds ~45k message files. Most correct: a message's `receivedAt` is when the
|
|
960
|
+
* mail was *sent* (an mbox backfill writes month-old values today), so parsing
|
|
961
|
+
* message bodies would answer a different question than "did this machine
|
|
962
|
+
* ingest anything recently?".
|
|
963
|
+
*/
|
|
964
|
+
function localMailStoreProbe(deps, messagesDir, listing) {
|
|
965
|
+
if (!listing.readable) {
|
|
966
|
+
return {
|
|
967
|
+
observation: { kind: "unknown", reason: `${messagesDir} exists but could not be listed` },
|
|
968
|
+
provenance: `attempted directory listing of ${messagesDir}`,
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
return (0, freshness_1.observeCreatePerEventStore)(messagesDir, deps, { hasEntries: listing.count > 0 });
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Hosted Mailroom liveness signal.
|
|
975
|
+
*
|
|
976
|
+
* Once an agent is cut over to the hosted store, messages live in the Blob
|
|
977
|
+
* container and nothing writes to `state/mailroom/messages` any more, so its
|
|
978
|
+
* mtime freezes at the cutover. Reading it in hosted mode is how this check
|
|
979
|
+
* reported "no mail ingested in 77 days" on 2026-07-27 while the container was
|
|
980
|
+
* taking mail that same morning — a false failure, which trains operators to
|
|
981
|
+
* ignore the check and so undoes the reason it exists.
|
|
982
|
+
*
|
|
983
|
+
* The local artifact that does still move is the hosted reader's search cache:
|
|
984
|
+
* `AzureBlobMailroomStore` writes `state/mail-search/<messageId>.json` for
|
|
985
|
+
* every message it decrypts (see `mailroom/reader.ts`, which hands the hosted
|
|
986
|
+
* store that cache directory). Message ids are content hashes, so a *new* file
|
|
987
|
+
* appears exactly when this machine sees a message it has never seen before;
|
|
988
|
+
* re-reading old mail rewrites existing files and leaves the directory mtime
|
|
989
|
+
* untouched. Same create-per-event shape as the local store, same O(1) stat.
|
|
990
|
+
*
|
|
991
|
+
* It is deliberately one step downstream of ingest: it proves hosted mail
|
|
992
|
+
* reached this machine, which is the strongest claim available without network
|
|
993
|
+
* access or Azure credentials — a health check must need neither. The activity
|
|
994
|
+
* wording, provenance and remediation all say so rather than implying doctor
|
|
995
|
+
* measured the container itself, and an empty mirror reports `unknown` rather
|
|
996
|
+
* than the hosted store being empty.
|
|
997
|
+
*/
|
|
998
|
+
function hostedMailMirrorProbe(deps, agentDir, storeLabel) {
|
|
999
|
+
const mirrorDir = `${deps.bundlesRoot}/${agentDir}/state/mail-search`;
|
|
1000
|
+
const listing = listJsonDocuments(deps, mirrorDir);
|
|
1001
|
+
if (!listing.readable) {
|
|
1002
|
+
return {
|
|
1003
|
+
observation: { kind: "unknown", reason: `${mirrorDir} exists but could not be listed` },
|
|
1004
|
+
provenance: `attempted directory listing of ${mirrorDir}`,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
return (0, freshness_1.observeMirroredStore)(mirrorDir, deps, { hasEntries: listing.count > 0, remote: storeLabel });
|
|
1008
|
+
}
|
|
878
1009
|
/**
|
|
879
1010
|
* Mail-ingest liveness — "is mail still arriving?", as opposed to the check
|
|
880
1011
|
* above, which only says "is a mailbox configured?".
|
|
@@ -883,30 +1014,27 @@ function listStoredMailMessages(deps, messagesDir) {
|
|
|
883
1014
|
* its config, and a cumulative count of 45,479 messages all reported ✔ for 77
|
|
884
1015
|
* days while zero mail was ingested.
|
|
885
1016
|
*
|
|
886
|
-
*
|
|
887
|
-
* and the
|
|
888
|
-
*
|
|
889
|
-
* `receivedAt` is when the mail was *sent* (an mbox backfill writes month-old
|
|
890
|
-
* values today), so parsing message bodies would answer a different question
|
|
891
|
-
* than "did this machine ingest anything recently?".
|
|
1017
|
+
* Which signal is honest depends on where the store lives, so the probe, the
|
|
1018
|
+
* activity wording and the remediation are all chosen per store kind. Reusing
|
|
1019
|
+
* the local path in hosted mode measures a directory nothing writes to.
|
|
892
1020
|
*/
|
|
893
|
-
function mailIngestLivenessCheck(deps, agentDir, mailroomRoot, messagesDir, listing) {
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
provenance: `attempted directory listing of ${messagesDir}`,
|
|
899
|
-
};
|
|
1021
|
+
function mailIngestLivenessCheck(deps, agentDir, mailroomRoot, messagesDir, listing, store) {
|
|
1022
|
+
const hosted = store.hosted ? store : null;
|
|
1023
|
+
const probe = hosted
|
|
1024
|
+
? hostedMailMirrorProbe(deps, agentDir, hosted.label)
|
|
1025
|
+
: localMailStoreProbe(deps, messagesDir, listing);
|
|
900
1026
|
return pipelineLivenessCheck({
|
|
901
1027
|
id: "mail.ingest_liveness",
|
|
902
1028
|
label: `${agentDir} mail ingest liveness`,
|
|
903
|
-
activity: "mail ingested",
|
|
1029
|
+
activity: hosted ? "hosted mail observed locally" : "mail ingested",
|
|
904
1030
|
unit: "message",
|
|
905
1031
|
probe,
|
|
906
1032
|
thresholds: (0, freshness_1.resolveFreshnessThresholds)(exports.DEFAULT_MAIL_INGEST_THRESHOLDS, senseFreshnessOverride(deps, agentDir, "mail")),
|
|
907
|
-
remediation:
|
|
1033
|
+
remediation: hosted
|
|
1034
|
+
? `doctor measures hosted mail only through this machine's local mirror and makes no network calls — confirm the container itself by listing \`messages/\` blobs in ${hosted.label} by Last-Modified, or read the mailbox directly (\`ouro mailbox\`, or the agent's \`mail_recent\` tool); if the mirror is empty or stale while the container is current, the hosted reader on this machine is not running`
|
|
1035
|
+
: "mail is configured but nothing is arriving — re-check the mailbox grant and keyIds against the vault (a server-side key rotation silently orphans ingestion), run `ouro connect mail --agent <agent>`, and inspect the mailroom ingress logs",
|
|
908
1036
|
configuredSinceMs: pathMtimeMs(deps, `${mailroomRoot}/registry.json`),
|
|
909
|
-
context: "mailbox configured",
|
|
1037
|
+
context: hosted ? `mailbox configured; ${hosted.label}` : "mailbox configured",
|
|
910
1038
|
nowMs: Date.now(),
|
|
911
1039
|
});
|
|
912
1040
|
}
|
|
@@ -35,6 +35,7 @@ exports.formatFreshnessAge = formatFreshnessAge;
|
|
|
35
35
|
exports.formatFreshnessTimestamp = formatFreshnessTimestamp;
|
|
36
36
|
exports.evaluateFreshness = evaluateFreshness;
|
|
37
37
|
exports.observeCreatePerEventStore = observeCreatePerEventStore;
|
|
38
|
+
exports.observeMirroredStore = observeMirroredStore;
|
|
38
39
|
exports.observeAppendPerEventStore = observeAppendPerEventStore;
|
|
39
40
|
exports.resolveFreshnessThresholds = resolveFreshnessThresholds;
|
|
40
41
|
const runtime_1 = require("../../nerves/runtime");
|
|
@@ -215,6 +216,46 @@ function observeCreatePerEventStore(dir, deps, options) {
|
|
|
215
216
|
};
|
|
216
217
|
}
|
|
217
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Last-observation probe for a **local mirror of a remote store** — the cache
|
|
221
|
+
* this machine writes as it reads a store that lives somewhere else, such as
|
|
222
|
+
* the hosted Mailroom's `state/mail-search/<messageId>.json` documents.
|
|
223
|
+
*
|
|
224
|
+
* Mechanically this is `observeCreatePerEventStore`: one file per event, named
|
|
225
|
+
* by a content-addressed id, so the directory mtime answers "when did this
|
|
226
|
+
* machine last record something it had never seen before" for O(1) cost.
|
|
227
|
+
*
|
|
228
|
+
* One rule differs, and it is the entire reason this exists: an absent or empty
|
|
229
|
+
* mirror is `unknown`, never `none`. For a local store the directory *is* the
|
|
230
|
+
* store, so empty means "nothing was ever delivered". For a mirror the
|
|
231
|
+
* authoritative store is remote and deliberately not read here — a health check
|
|
232
|
+
* must not need network access or credentials — so empty means only "this
|
|
233
|
+
* machine holds no record". That is absence of evidence, and rendering it as a
|
|
234
|
+
* confident "never delivered" would be the same crying-wolf failure as reading
|
|
235
|
+
* a local directory that nothing writes to any more.
|
|
236
|
+
*/
|
|
237
|
+
function observeMirroredStore(dir, deps, options) {
|
|
238
|
+
const provenance = `derived from the local mirror at ${dir} (directory mtime, O(1), no per-entry scan); the authoritative store is ${options.remote}, which doctor does not read`;
|
|
239
|
+
const exists = deps.existsSync(dir);
|
|
240
|
+
if (!exists || !options.hasEntries) {
|
|
241
|
+
return {
|
|
242
|
+
observation: {
|
|
243
|
+
kind: "unknown",
|
|
244
|
+
reason: `the local mirror at ${dir} is ${exists ? "empty" : "absent"}, and ${options.remote} is not read by doctor (no network calls, no credentials), so recency cannot be measured on this machine`,
|
|
245
|
+
},
|
|
246
|
+
provenance,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
return { observation: { kind: "activity", atMs: deps.statSync(dir).mtimeMs }, provenance };
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
return {
|
|
254
|
+
observation: { kind: "unknown", reason: `${dir} could not be stat'd: ${error instanceof Error ? error.message : String(error)}` },
|
|
255
|
+
provenance,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
218
259
|
/**
|
|
219
260
|
* Last-write probe for **append-per-event** stores — a small set of long-lived
|
|
220
261
|
* logs appended in place, such as the BlueBubbles inbound ndjson (one file per
|
|
@@ -72,9 +72,17 @@ function extractOrderedListReferents(text) {
|
|
|
72
72
|
}
|
|
73
73
|
return referents;
|
|
74
74
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Returns the marker that fired, not just a boolean: when the hold blocks an action
|
|
77
|
+
* the agent has to be able to see *which* word triggered it. In the 2026-07-27
|
|
78
|
+
* incident it guessed wrong twice ("it's because of the word 'useful'") because the
|
|
79
|
+
* block message named no trigger.
|
|
80
|
+
*/
|
|
81
|
+
function correctionMarkerIn(text) {
|
|
82
|
+
const phrase = /\b(hang on|wait|actually|not that|not this|wrong|misunderstood|you(?:'re| are) right|correct)\b/i.exec(text);
|
|
83
|
+
if (phrase)
|
|
84
|
+
return phrase[0].toLowerCase();
|
|
85
|
+
return /^\s*no[\s,]/i.test(text) ? "no" : undefined;
|
|
78
86
|
}
|
|
79
87
|
function isTerseReferent(text) {
|
|
80
88
|
const normalized = text.trim().toLowerCase().replace(/[.!?]+$/g, "");
|
|
@@ -93,12 +101,16 @@ function shouldAttachStructuredOutput(combinedSpeech, signals) {
|
|
|
93
101
|
return true;
|
|
94
102
|
return signals.some((signal) => signal === "terse_referent" || signal === "correction_marker");
|
|
95
103
|
}
|
|
96
|
-
function deriveSignals(currentUserSpeech, priorAssistantReferents, latestStructuredOutput) {
|
|
104
|
+
function deriveSignals(currentUserSpeech, priorAssistantReferents, latestStructuredOutput, speechKind) {
|
|
97
105
|
const combined = currentUserSpeech.join("\n").trim();
|
|
98
|
-
|
|
106
|
+
// A reaction is an acknowledgement of an existing message, not new speech. Its
|
|
107
|
+
// text is synthesised by the sense from the message being reacted to, so scanning
|
|
108
|
+
// it would read the *quoted* message's words as the human's correction — and a
|
|
109
|
+
// positive tapback is approval, which must never gate outbound work.
|
|
110
|
+
if (!combined || speechKind === "reaction")
|
|
99
111
|
return [];
|
|
100
112
|
const signals = [];
|
|
101
|
-
if (
|
|
113
|
+
if (correctionMarkerIn(combined))
|
|
102
114
|
signals.push("correction_marker");
|
|
103
115
|
if (priorAssistantReferents.length > 0 && isTerseReferent(combined))
|
|
104
116
|
signals.push("terse_referent");
|
|
@@ -106,15 +118,42 @@ function deriveSignals(currentUserSpeech, priorAssistantReferents, latestStructu
|
|
|
106
118
|
signals.push("structured_referent");
|
|
107
119
|
return [...new Set(signals)];
|
|
108
120
|
}
|
|
109
|
-
|
|
110
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The hold exists for corrections that are terse or ambiguous ("no, not that one").
|
|
123
|
+
* A correction word buried in a long, self-contained instruction is neither: on
|
|
124
|
+
* 2026-07-27 the word "actually" — in "Do NOT claim a hotel has AC unless you
|
|
125
|
+
* actually saw evidence" — armed the hold inside a 195-word directive that had zero
|
|
126
|
+
* prior referents and no structured output, and blocked five consecutive
|
|
127
|
+
* `send_message` calls. The same directive was what authorised the send, and with
|
|
128
|
+
* nothing to disambiguate the remedy the block named could not be performed.
|
|
129
|
+
*/
|
|
130
|
+
const TERSE_CORRECTION_MAX_WORDS = 12;
|
|
131
|
+
const CORRECTION_HOLD_CLEARED_BY = "Resolve the referent (orientation_get lists the candidates) or restate the request as a standalone, correction-free instruction. The policy is recomputed from each user turn, so it never carries past this turn.";
|
|
132
|
+
function countWords(text) {
|
|
133
|
+
return text.trim().split(/\s+/).filter(Boolean).length;
|
|
134
|
+
}
|
|
135
|
+
function correctionMarkerArmsHold(combinedSpeech, priorAssistantReferents, latestStructuredOutput) {
|
|
136
|
+
if (countWords(combinedSpeech) <= TERSE_CORRECTION_MAX_WORDS)
|
|
137
|
+
return true;
|
|
138
|
+
// Long speech still holds when there is something concrete to disambiguate.
|
|
139
|
+
return priorAssistantReferents.length > 0 || Boolean(latestStructuredOutput);
|
|
140
|
+
}
|
|
141
|
+
function deriveActionPolicy(signals, combinedSpeech, priorAssistantReferents, latestStructuredOutput) {
|
|
142
|
+
const holdSignals = signals.filter((signal) => signal !== "correction_marker"
|
|
143
|
+
|| correctionMarkerArmsHold(combinedSpeech, priorAssistantReferents, latestStructuredOutput));
|
|
144
|
+
if (holdSignals.length === 0)
|
|
111
145
|
return { mode: "normal" };
|
|
146
|
+
const triggeredBy = holdSignals.includes("correction_marker")
|
|
147
|
+
? correctionMarkerIn(combinedSpeech)
|
|
148
|
+
: undefined;
|
|
112
149
|
return {
|
|
113
150
|
mode: "correction_hold",
|
|
114
|
-
reason:
|
|
151
|
+
reason: holdSignals.includes("terse_referent")
|
|
115
152
|
? "Current user speech appears referent-dependent; inspect orientation before mutating durable state."
|
|
116
153
|
: "Current user speech appears to correct or revise prior understanding; inspect orientation before mutating durable state.",
|
|
117
154
|
blockedMutationKinds: ["durable_state_write", "external_side_effect"],
|
|
155
|
+
...(triggeredBy ? { triggeredBy } : {}),
|
|
156
|
+
clearedBy: CORRECTION_HOLD_CLEARED_BY,
|
|
118
157
|
};
|
|
119
158
|
}
|
|
120
159
|
function buildOrientationFrame(input) {
|
|
@@ -127,12 +166,13 @@ function buildOrientationFrame(input) {
|
|
|
127
166
|
const previousAssistant = latestAssistantBefore(input.messages, derivedCurrent.firstIndex);
|
|
128
167
|
const priorAssistantReferents = extractOrderedListReferents(extractMessageText(previousAssistant));
|
|
129
168
|
const candidateStructuredOutput = latestStructuredOutputFrom(input.structuredOutputs);
|
|
130
|
-
const
|
|
169
|
+
const speechKind = input.speechKind ?? "utterance";
|
|
170
|
+
const signals = deriveSignals(currentUserSpeech, priorAssistantReferents, candidateStructuredOutput, speechKind);
|
|
131
171
|
const combinedSpeech = currentUserSpeech.join("\n").trim();
|
|
132
172
|
const latestStructuredOutput = candidateStructuredOutput && shouldAttachStructuredOutput(combinedSpeech, signals)
|
|
133
173
|
? candidateStructuredOutput
|
|
134
174
|
: undefined;
|
|
135
|
-
const actionPolicy = deriveActionPolicy(signals);
|
|
175
|
+
const actionPolicy = deriveActionPolicy(signals, combinedSpeech, priorAssistantReferents, candidateStructuredOutput);
|
|
136
176
|
const frame = {
|
|
137
177
|
schemaVersion: 1,
|
|
138
178
|
channel: input.channel,
|
|
@@ -142,6 +182,7 @@ function buildOrientationFrame(input) {
|
|
|
142
182
|
signals,
|
|
143
183
|
actionPolicy,
|
|
144
184
|
...(input.source ? { source: input.source } : {}),
|
|
185
|
+
...(speechKind === "utterance" ? {} : { speechKind }),
|
|
145
186
|
};
|
|
146
187
|
(0, runtime_1.emitNervesEvent)({
|
|
147
188
|
component: "engine",
|
|
@@ -155,6 +196,27 @@ function buildOrientationFrame(input) {
|
|
|
155
196
|
policy: actionPolicy.mode,
|
|
156
197
|
},
|
|
157
198
|
});
|
|
199
|
+
if (actionPolicy.mode === "correction_hold") {
|
|
200
|
+
// Distinct + greppable: a hold that blocks an explicitly requested action must
|
|
201
|
+
// leave a trace naming what it blocks and what releases it.
|
|
202
|
+
(0, runtime_1.emitNervesEvent)({
|
|
203
|
+
level: "warn",
|
|
204
|
+
component: "engine",
|
|
205
|
+
event: "orientation.correction_hold_armed",
|
|
206
|
+
message: "orientation correction hold armed",
|
|
207
|
+
meta: {
|
|
208
|
+
channel: input.channel,
|
|
209
|
+
speechKind,
|
|
210
|
+
signals,
|
|
211
|
+
blockedMutationKinds: actionPolicy.blockedMutationKinds,
|
|
212
|
+
triggeredBy: actionPolicy.triggeredBy ?? null,
|
|
213
|
+
referentCount: priorAssistantReferents.length,
|
|
214
|
+
structuredOutput: candidateStructuredOutput?.id ?? null,
|
|
215
|
+
speechWordCount: countWords(combinedSpeech),
|
|
216
|
+
clearedBy: actionPolicy.clearedBy,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
}
|
|
158
220
|
return frame;
|
|
159
221
|
}
|
|
160
222
|
function renderOrientationFrame(frame) {
|
|
@@ -165,6 +227,15 @@ function renderOrientationFrame(frame) {
|
|
|
165
227
|
];
|
|
166
228
|
if (frame.actionPolicy.mode === "correction_hold") {
|
|
167
229
|
lines.push(`policy reason: ${frame.actionPolicy.reason}`);
|
|
230
|
+
lines.push(`policy blocks: ${frame.actionPolicy.blockedMutationKinds.join(", ")}`);
|
|
231
|
+
if (frame.actionPolicy.triggeredBy) {
|
|
232
|
+
lines.push(`policy trigger: correction marker "${frame.actionPolicy.triggeredBy}" in current user speech`);
|
|
233
|
+
}
|
|
234
|
+
if (frame.actionPolicy.clearedBy)
|
|
235
|
+
lines.push(`policy clears when: ${frame.actionPolicy.clearedBy}`);
|
|
236
|
+
}
|
|
237
|
+
if (frame.speechKind) {
|
|
238
|
+
lines.push(`speech kind: ${frame.speechKind}`);
|
|
168
239
|
}
|
|
169
240
|
if (frame.source) {
|
|
170
241
|
lines.push("source:");
|
|
@@ -687,5 +687,54 @@ function createBlueBubblesClient(config = (0, config_1.getBlueBubblesConfig)(),
|
|
|
687
687
|
return null;
|
|
688
688
|
}
|
|
689
689
|
},
|
|
690
|
+
async getMessageDetails(messageGuid) {
|
|
691
|
+
const url = buildRepairUrl(config.serverUrl, messageGuid, config.password);
|
|
692
|
+
try {
|
|
693
|
+
const response = await fetch(url, {
|
|
694
|
+
method: "GET",
|
|
695
|
+
signal: AbortSignal.timeout(channelConfig.requestTimeoutMs),
|
|
696
|
+
});
|
|
697
|
+
if (!response.ok) {
|
|
698
|
+
(0, runtime_1.emitNervesEvent)({
|
|
699
|
+
level: "warn",
|
|
700
|
+
component: "senses",
|
|
701
|
+
event: "senses.bluebubbles_get_message_details_error",
|
|
702
|
+
message: "failed to fetch message details",
|
|
703
|
+
meta: { messageGuid, status: response.status },
|
|
704
|
+
});
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
const data = extractRepairData(await parseJsonBody(response));
|
|
708
|
+
if (!data) {
|
|
709
|
+
(0, runtime_1.emitNervesEvent)({
|
|
710
|
+
level: "warn",
|
|
711
|
+
component: "senses",
|
|
712
|
+
event: "senses.bluebubbles_get_message_details_error",
|
|
713
|
+
message: "message payload was not a record",
|
|
714
|
+
meta: { messageGuid },
|
|
715
|
+
});
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
const text = typeof data.text === "string" ? data.text.trim() || null : null;
|
|
719
|
+
const fromMe = typeof data.isFromMe === "boolean" ? data.isFromMe : null;
|
|
720
|
+
(0, runtime_1.emitNervesEvent)({
|
|
721
|
+
component: "senses",
|
|
722
|
+
event: "senses.bluebubbles_get_message_details",
|
|
723
|
+
message: "fetched message details by guid",
|
|
724
|
+
meta: { messageGuid, hasText: text !== null, hasAuthorship: fromMe !== null },
|
|
725
|
+
});
|
|
726
|
+
return { text, fromMe };
|
|
727
|
+
}
|
|
728
|
+
catch (error) {
|
|
729
|
+
(0, runtime_1.emitNervesEvent)({
|
|
730
|
+
level: "warn",
|
|
731
|
+
component: "senses",
|
|
732
|
+
event: "senses.bluebubbles_get_message_details_error",
|
|
733
|
+
message: "exception fetching message details",
|
|
734
|
+
meta: { messageGuid, reason: error instanceof Error ? error.message : String(error) },
|
|
735
|
+
});
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
},
|
|
690
739
|
};
|
|
691
740
|
}
|
|
@@ -34,7 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.BB_IN_FLIGHT_MAX_AGE_MS = void 0;
|
|
37
|
-
exports.
|
|
37
|
+
exports.resolveReactionTarget = resolveReactionTarget;
|
|
38
38
|
exports.tokenizeForDedupe = tokenizeForDedupe;
|
|
39
39
|
exports.jaccardSimilarity = jaccardSimilarity;
|
|
40
40
|
exports.createStatusBatcher = createStatusBatcher;
|
|
@@ -116,16 +116,19 @@ const bbInFlightMessageClaims = new Map();
|
|
|
116
116
|
* a leaked marker from blocking forward progress for hours.
|
|
117
117
|
*/
|
|
118
118
|
exports.BB_IN_FLIGHT_MAX_AGE_MS = 15 * 60_000;
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
119
|
+
// Resolve the message a reaction points at, so the agent is told *what* was
|
|
120
|
+
// reacted to and *whose* message it was rather than a bare "reacted with love".
|
|
121
|
+
// Prefers the authorship-carrying lookup; falls back to text-only when the client
|
|
122
|
+
// does not provide one. Every failure path returns an explicit "unknown" rather
|
|
123
|
+
// than a value the caller could mistake for a resolved target.
|
|
124
|
+
async function resolveReactionTarget(client, targetMessageGuid) {
|
|
125
|
+
const base = { guid: targetMessageGuid };
|
|
126
|
+
if (client.getMessageDetails) {
|
|
127
|
+
const details = await client.getMessageDetails(targetMessageGuid).catch(() => null);
|
|
128
|
+
return { ...base, text: details?.text ?? null, fromMe: details?.fromMe ?? null };
|
|
129
|
+
}
|
|
130
|
+
const text = await client.getMessageText(targetMessageGuid).catch(() => null);
|
|
131
|
+
return { ...base, text, fromMe: null };
|
|
129
132
|
}
|
|
130
133
|
// ── Near-duplicate outward-text detection ────────────────────────
|
|
131
134
|
// Used by createBlueBubblesCallbacks to collapse mid-turn rephrasings of the
|
|
@@ -1390,13 +1393,14 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
|
|
|
1390
1393
|
meta: { threadGuid, hasText: !!repliedToText },
|
|
1391
1394
|
});
|
|
1392
1395
|
}
|
|
1393
|
-
//
|
|
1396
|
+
// Anchor reaction mutations to the message they point at, including whose
|
|
1397
|
+
// message it was. Unresolved targets keep the explicit "unidentified message"
|
|
1398
|
+
// wording that normalizeBlueBubblesEvent already produced.
|
|
1394
1399
|
const isReaction = event.kind === "mutation" && event.mutationType === "reaction";
|
|
1395
|
-
if (isReaction && event.targetMessageGuid) {
|
|
1396
|
-
/* v8 ignore start -- best-effort lookup;
|
|
1397
|
-
const
|
|
1398
|
-
|
|
1399
|
-
event.textForAgent = enrichReactionText(event.textForAgent, originalText, 80);
|
|
1400
|
+
if (isReaction && event.reaction && event.targetMessageGuid) {
|
|
1401
|
+
/* v8 ignore start -- best-effort network lookup; resolveReactionTarget and renderBlueBubblesReactionText are covered by unit tests @preserve */
|
|
1402
|
+
const target = await resolveReactionTarget(client, event.targetMessageGuid);
|
|
1403
|
+
event.textForAgent = (0, model_1.renderBlueBubblesReactionText)(event.reaction, target);
|
|
1400
1404
|
/* v8 ignore stop */
|
|
1401
1405
|
}
|
|
1402
1406
|
// Build inbound user message as user-visible speech only; source/routing facts live in the orientation frame.
|
|
@@ -1411,6 +1415,7 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
|
|
|
1411
1415
|
currentUserMessages: [userMessage],
|
|
1412
1416
|
structuredOutputs: existing?.structuredOutputs ?? [],
|
|
1413
1417
|
source: buildBlueBubblesOrientationSource(event, priorMessages, repliedToText),
|
|
1418
|
+
...(isReaction ? { speechKind: "reaction" } : {}),
|
|
1414
1419
|
});
|
|
1415
1420
|
const visibleActivityTurnId = activeTurnId;
|
|
1416
1421
|
const callbacks = createBlueBubblesCallbacks(client, event.chat, replyTarget, event.chat.isGroup, visibleActivityTurnId
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.BlueBubblesIgnoredEventError = void 0;
|
|
4
|
+
exports.describeBlueBubblesReaction = describeBlueBubblesReaction;
|
|
5
|
+
exports.renderBlueBubblesReactionText = renderBlueBubblesReactionText;
|
|
4
6
|
exports.normalizeBlueBubblesEvent = normalizeBlueBubblesEvent;
|
|
5
7
|
const runtime_1 = require("../../nerves/runtime");
|
|
6
8
|
const render_1 = require("../../heart/attachments/render");
|
|
@@ -150,11 +152,70 @@ function formatMessageText(data, attachments) {
|
|
|
150
152
|
return formatAttachmentText(attachments);
|
|
151
153
|
}
|
|
152
154
|
function normalizeReactionName(value) {
|
|
155
|
+
// chat.db stores associated_message_type as an integer; BlueBubbles forwards it
|
|
156
|
+
// as either the integer, its decimal string, or a name ("love", "-love").
|
|
157
|
+
if (typeof value === "number") {
|
|
158
|
+
return Number.isFinite(value) && value !== 0 ? String(value) : undefined;
|
|
159
|
+
}
|
|
153
160
|
if (typeof value !== "string")
|
|
154
161
|
return undefined;
|
|
155
162
|
const trimmed = value.trim();
|
|
156
163
|
return trimmed ? trimmed.toLowerCase() : undefined;
|
|
157
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* iMessage tapbacks: associated_message_type 2000-2005 add a tapback, 3000-3005
|
|
167
|
+
* remove the same tapback. BlueBubbles may send either the code or the name.
|
|
168
|
+
*/
|
|
169
|
+
const REACTION_VOCABULARY = [
|
|
170
|
+
{ code: 2000, name: "love", verb: "loved", noun: "love" },
|
|
171
|
+
{ code: 2001, name: "like", verb: "liked", noun: "like" },
|
|
172
|
+
{ code: 2002, name: "dislike", verb: "disliked", noun: "dislike" },
|
|
173
|
+
{ code: 2003, name: "laugh", verb: "laughed at", noun: "laugh" },
|
|
174
|
+
{ code: 2004, name: "emphasize", verb: "emphasized", noun: "emphasis" },
|
|
175
|
+
{ code: 2005, name: "question", verb: "questioned", noun: "question" },
|
|
176
|
+
];
|
|
177
|
+
const REACTION_EXCERPT_MAX_LENGTH = 80;
|
|
178
|
+
function describeBlueBubblesReaction(raw) {
|
|
179
|
+
const stripped = raw.startsWith("-") ? raw.slice(1) : raw;
|
|
180
|
+
const code = /^\d+$/.test(stripped) ? Number(stripped) : undefined;
|
|
181
|
+
const removal = raw.startsWith("-") || (code !== undefined && code >= 3000 && code < 4000);
|
|
182
|
+
const action = removal ? "remove" : "add";
|
|
183
|
+
const entry = REACTION_VOCABULARY.find((candidate) => candidate.name === stripped || candidate.code === code || candidate.code + 1000 === code);
|
|
184
|
+
if (!entry)
|
|
185
|
+
return { raw, action };
|
|
186
|
+
return { raw, action, verb: entry.verb, noun: entry.noun };
|
|
187
|
+
}
|
|
188
|
+
function reactionExcerpt(text) {
|
|
189
|
+
return text.length > REACTION_EXCERPT_MAX_LENGTH
|
|
190
|
+
? `${text.slice(0, REACTION_EXCERPT_MAX_LENGTH - 3)}...`
|
|
191
|
+
: text;
|
|
192
|
+
}
|
|
193
|
+
function reactionTargetPhrase(target) {
|
|
194
|
+
const text = target.text?.trim();
|
|
195
|
+
if (text) {
|
|
196
|
+
const owner = target.fromMe === true ? "your" : target.fromMe === false ? "their" : "a";
|
|
197
|
+
return `${owner} message: "${reactionExcerpt(text)}"`;
|
|
198
|
+
}
|
|
199
|
+
// Never hand the agent a bare "reacted with love" stub — say plainly that the
|
|
200
|
+
// referent is missing so it can ask instead of guessing what was approved.
|
|
201
|
+
return target.guid
|
|
202
|
+
? `an unidentified message (target guid ${target.guid}; its text could not be resolved)`
|
|
203
|
+
: "an unidentified message (the reaction carried no target message reference)";
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Render a reaction as speech the agent can act on: what the tapback was, whether
|
|
207
|
+
* it was added or removed, and which message it points at.
|
|
208
|
+
*/
|
|
209
|
+
function renderBlueBubblesReactionText(reaction, target) {
|
|
210
|
+
const phrase = reactionTargetPhrase(target);
|
|
211
|
+
if (reaction.action === "remove") {
|
|
212
|
+
const noun = reaction.noun ?? reaction.raw.replace(/^-/, "");
|
|
213
|
+
return `removed their ${noun} reaction from ${phrase}`;
|
|
214
|
+
}
|
|
215
|
+
return reaction.verb
|
|
216
|
+
? `${reaction.verb} ${phrase}`
|
|
217
|
+
: `reacted with ${reaction.raw} to ${phrase}`;
|
|
218
|
+
}
|
|
158
219
|
function stripPartPrefix(guid) {
|
|
159
220
|
if (!guid)
|
|
160
221
|
return undefined;
|
|
@@ -162,10 +223,10 @@ function stripPartPrefix(guid) {
|
|
|
162
223
|
const marker = trimmed.lastIndexOf("/");
|
|
163
224
|
return marker >= 0 ? trimmed.slice(marker + 1) : trimmed;
|
|
164
225
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
226
|
+
// Reactions never reach here: `detectMutationType` returns "reaction" only when a
|
|
227
|
+
// reaction name was decoded, and that same name produces the descriptor the caller
|
|
228
|
+
// renders with `renderBlueBubblesReactionText` instead.
|
|
229
|
+
function buildMutationText(mutationType, data) {
|
|
169
230
|
if (mutationType === "edit") {
|
|
170
231
|
const editedText = readString(data, "text")?.trim() ?? "";
|
|
171
232
|
return editedText ? `edited message: ${editedText}` : "edited a message";
|
|
@@ -228,6 +289,10 @@ function normalizeBlueBubblesEvent(payload) {
|
|
|
228
289
|
const fromMe = readBoolean(data, "isFromMe") ?? false;
|
|
229
290
|
const attachments = extractAttachments(data);
|
|
230
291
|
const reactionName = normalizeReactionName(data.associatedMessageType);
|
|
292
|
+
const reaction = reactionName ? describeBlueBubblesReaction(reactionName) : undefined;
|
|
293
|
+
const targetMessageGuid = reaction
|
|
294
|
+
? stripPartPrefix(readString(data, "associatedMessageGuid"))
|
|
295
|
+
: undefined;
|
|
231
296
|
const mutationType = detectMutationType(eventType, data, reactionName);
|
|
232
297
|
const requiresRepair = (readBoolean(data, "hasPayloadData") ?? false) ||
|
|
233
298
|
attachments.length > 0 ||
|
|
@@ -238,16 +303,17 @@ function normalizeBlueBubblesEvent(payload) {
|
|
|
238
303
|
eventType,
|
|
239
304
|
mutationType,
|
|
240
305
|
messageGuid,
|
|
241
|
-
targetMessageGuid
|
|
242
|
-
? stripPartPrefix(readString(data, "associatedMessageGuid"))
|
|
243
|
-
: undefined,
|
|
306
|
+
targetMessageGuid,
|
|
244
307
|
timestamp,
|
|
245
308
|
fromMe,
|
|
246
309
|
sender,
|
|
247
310
|
chat,
|
|
248
311
|
shouldNotifyAgent: mutationType === "reaction" || mutationType === "edit" || mutationType === "unsend",
|
|
249
|
-
textForAgent:
|
|
312
|
+
textForAgent: reaction
|
|
313
|
+
? renderBlueBubblesReactionText(reaction, { guid: targetMessageGuid })
|
|
314
|
+
: buildMutationText(mutationType, data),
|
|
250
315
|
requiresRepair,
|
|
316
|
+
...(reaction ? { reaction } : {}),
|
|
251
317
|
}
|
|
252
318
|
: {
|
|
253
319
|
kind: "message",
|
package/dist/senses/mail.js
CHANGED
|
@@ -50,6 +50,125 @@ const smtp_ingress_1 = require("../mailroom/smtp-ingress");
|
|
|
50
50
|
function readRegistry(registryPath) {
|
|
51
51
|
return JSON.parse(fs.readFileSync(registryPath, "utf-8"));
|
|
52
52
|
}
|
|
53
|
+
function isRecord(value) {
|
|
54
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
55
|
+
}
|
|
56
|
+
function unverifiableCoverage(checkedAt, reason) {
|
|
57
|
+
return { status: "could-not-verify", declaredKeyIds: [], absentKeyIds: [], reason, checkedAt };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Key ids the registry says mail can currently be encrypted to for this agent.
|
|
61
|
+
*
|
|
62
|
+
* Filtered to this agent because the hosted registry is domain-wide: counting
|
|
63
|
+
* every agent's records would report another agent's keys as absent on every
|
|
64
|
+
* cycle, and a check that always fails is a check operators learn to ignore.
|
|
65
|
+
*
|
|
66
|
+
* The match is case-insensitive. The harness sends `agentId: agent` verbatim to
|
|
67
|
+
* hosted Mail Control while this repo's own hosted ensure-response fixture
|
|
68
|
+
* returns the lowercased form for that same request, so a registry record's
|
|
69
|
+
* agent id can differ from the sense's agent name by case alone. Case-sensitive
|
|
70
|
+
* matching would then declare nothing and report `could-not-verify` forever —
|
|
71
|
+
* the same silence this probe exists to end. Widening the match cannot produce
|
|
72
|
+
* a false `absent` unless two agents differ only by case, which the store and
|
|
73
|
+
* address resolvers could not tell apart either.
|
|
74
|
+
*
|
|
75
|
+
* Disabled source grants are excluded because `resolveMailAddress`
|
|
76
|
+
* (`mailroom/core.ts`) refuses their alias outright, so no new mail can be
|
|
77
|
+
* encrypted to their key and a missing private half is not a live gap.
|
|
78
|
+
*/
|
|
79
|
+
function registryDeclaredKeyIds(registry, agentName) {
|
|
80
|
+
const wanted = agentName.toLowerCase();
|
|
81
|
+
const isThisAgent = (agentId) => agentId.toLowerCase() === wanted;
|
|
82
|
+
const declared = [
|
|
83
|
+
...registry.mailboxes.filter((mailbox) => isThisAgent(mailbox.agentId)).map((mailbox) => mailbox.keyId),
|
|
84
|
+
...registry.sourceGrants.filter((grant) => isThisAgent(grant.agentId) && grant.enabled).map((grant) => grant.keyId),
|
|
85
|
+
];
|
|
86
|
+
return [...new Set(declared.filter((keyId) => keyId.trim().length > 0))];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Mail key ids the vault can actually decrypt with, read fresh rather than
|
|
90
|
+
* taken from the reader resolution captured at sense startup — a sense that has
|
|
91
|
+
* been up for days would otherwise measure a key set the vault has since
|
|
92
|
+
* changed, and report a rotation as a loss.
|
|
93
|
+
*
|
|
94
|
+
* Throws rather than returning empty when the read is untrustworthy, so the
|
|
95
|
+
* caller classifies it `could-not-verify` instead of `absent`. An empty-but-
|
|
96
|
+
* present `privateKeys` record is a real observation and is not a throw.
|
|
97
|
+
*/
|
|
98
|
+
function vaultMailKeyIds(agentName) {
|
|
99
|
+
const runtime = (0, runtime_credentials_1.readRuntimeCredentialConfig)(agentName);
|
|
100
|
+
if (!runtime.ok)
|
|
101
|
+
throw new Error(`vault ${runtime.itemPath} read is ${runtime.reason}`);
|
|
102
|
+
const mailroom = runtime.config.mailroom;
|
|
103
|
+
const privateKeys = isRecord(mailroom) ? mailroom.privateKeys : undefined;
|
|
104
|
+
if (!isRecord(privateKeys))
|
|
105
|
+
throw new Error(`vault ${runtime.itemPath} has no mailroom.privateKeys record`);
|
|
106
|
+
return Object.entries(privateKeys)
|
|
107
|
+
.filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0)
|
|
108
|
+
.map((entry) => entry[0]);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Never throws: every failure degrades to `could-not-verify`. `scan()` is
|
|
112
|
+
* awaited during sense startup, so a throw here would stop the mail sense from
|
|
113
|
+
* starting — an integrity probe must not be able to take mail down.
|
|
114
|
+
*/
|
|
115
|
+
async function computeMailKeyCoverage(input) {
|
|
116
|
+
try {
|
|
117
|
+
const registry = await input.readRegistry(input.config);
|
|
118
|
+
const declaredKeyIds = registryDeclaredKeyIds(registry, input.agentName);
|
|
119
|
+
if (declaredKeyIds.length === 0) {
|
|
120
|
+
// Nothing to check against is not the same as nothing wrong: reporting
|
|
121
|
+
// `covered` for an empty declaration would turn an unprovisioned or
|
|
122
|
+
// wrong registry into a green light.
|
|
123
|
+
return unverifiableCoverage(input.checkedAt, `mailroom registry declares no mail keys for ${input.agentName}`);
|
|
124
|
+
}
|
|
125
|
+
const present = new Set(vaultMailKeyIds(input.agentName));
|
|
126
|
+
const absentKeyIds = declaredKeyIds.filter((keyId) => !present.has(keyId));
|
|
127
|
+
return {
|
|
128
|
+
status: absentKeyIds.length === 0 ? "covered" : "absent",
|
|
129
|
+
declaredKeyIds,
|
|
130
|
+
absentKeyIds,
|
|
131
|
+
reason: null,
|
|
132
|
+
checkedAt: input.checkedAt,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
return unverifiableCoverage(input.checkedAt, error instanceof Error ? error.message : String(error));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Coverage has to push, not wait to be asked. `ouro doctor` is on-demand — it
|
|
141
|
+
* has no scheduled invocation — so a doctor line alone leaves a gap invisible
|
|
142
|
+
* until someone happens to run it, which is how `mail_slugger-hey_6e7b4bfa34c0b826`
|
|
143
|
+
* stayed unnoticed for 23 hours while mail encrypted to it piled up unreadable.
|
|
144
|
+
*/
|
|
145
|
+
function emitMailKeyCoverageEvents(agentName, coverage) {
|
|
146
|
+
(0, runtime_1.emitNervesEvent)({
|
|
147
|
+
component: "senses",
|
|
148
|
+
event: "senses.mail_key_coverage_recorded",
|
|
149
|
+
message: "mail key coverage recorded",
|
|
150
|
+
meta: {
|
|
151
|
+
agentName,
|
|
152
|
+
status: coverage.status,
|
|
153
|
+
declaredCount: coverage.declaredKeyIds.length,
|
|
154
|
+
absentCount: coverage.absentKeyIds.length,
|
|
155
|
+
reason: coverage.reason,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
if (coverage.status !== "absent")
|
|
159
|
+
return;
|
|
160
|
+
(0, runtime_1.emitNervesEvent)({
|
|
161
|
+
level: "error",
|
|
162
|
+
component: "senses",
|
|
163
|
+
event: "senses.mail_key_coverage_absent",
|
|
164
|
+
message: `mailroom registry declares mail keys with no private half in ${agentName}'s vault: ${coverage.absentKeyIds.join(", ")}`,
|
|
165
|
+
meta: {
|
|
166
|
+
agentName,
|
|
167
|
+
absentKeyIds: coverage.absentKeyIds,
|
|
168
|
+
declaredKeyIds: coverage.declaredKeyIds,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
53
172
|
function validPort(value) {
|
|
54
173
|
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 65535 ? value : 0;
|
|
55
174
|
}
|
|
@@ -280,6 +399,7 @@ async function startMailSenseApp(options) {
|
|
|
280
399
|
const importDiscoveryPath = importDiscoveryStatePath(options.agentName);
|
|
281
400
|
let lastScanAt = null;
|
|
282
401
|
let lastQueuedCount = 0;
|
|
402
|
+
let lastKeyCoverage = unverifiableCoverage(new Date(now()).toISOString(), "mail key coverage not probed yet");
|
|
283
403
|
const scan = async () => {
|
|
284
404
|
const scanStartedAt = new Date(now()).toISOString();
|
|
285
405
|
let queuedCount = 0;
|
|
@@ -322,6 +442,13 @@ async function startMailSenseApp(options) {
|
|
|
322
442
|
meta: { agentName: options.agentName, error: error instanceof Error ? error.message : String(error) },
|
|
323
443
|
});
|
|
324
444
|
}
|
|
445
|
+
lastKeyCoverage = await computeMailKeyCoverage({
|
|
446
|
+
agentName: options.agentName,
|
|
447
|
+
config: resolved.config,
|
|
448
|
+
readRegistry: options.readRegistry ?? reader_1.readMailroomRegistry,
|
|
449
|
+
checkedAt: scanStartedAt,
|
|
450
|
+
});
|
|
451
|
+
emitMailKeyCoverageEvents(options.agentName, lastKeyCoverage);
|
|
325
452
|
lastScanAt = scanStartedAt;
|
|
326
453
|
lastQueuedCount = queuedCount;
|
|
327
454
|
writeRuntimeState(runtimePath, {
|
|
@@ -336,6 +463,7 @@ async function startMailSenseApp(options) {
|
|
|
336
463
|
storeLabel: resolved.storeLabel,
|
|
337
464
|
lastScanAt,
|
|
338
465
|
lastQueuedCount,
|
|
466
|
+
keyCoverage: lastKeyCoverage,
|
|
339
467
|
updatedAt: new Date(now()).toISOString(),
|
|
340
468
|
});
|
|
341
469
|
};
|
|
@@ -382,6 +510,7 @@ async function startMailSenseApp(options) {
|
|
|
382
510
|
storeLabel: resolved.storeLabel,
|
|
383
511
|
lastScanAt,
|
|
384
512
|
lastQueuedCount,
|
|
513
|
+
keyCoverage: lastKeyCoverage,
|
|
385
514
|
updatedAt: new Date(now()).toISOString(),
|
|
386
515
|
});
|
|
387
516
|
(0, runtime_1.emitNervesEvent)({
|