@ouro.bot/cli 0.1.0-alpha.502 → 0.1.0-alpha.504

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.json CHANGED
@@ -1,6 +1,22 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.504",
6
+ "changes": [
7
+ "New `mail_outbox` tool — the agent can now introspect its own outbound mail (drafts, queued sends, delivered, bounced, etc.). The mail repertoire had `mail_compose`, `mail_send`, and `mail_recent` for inbound — but no symmetric way to ask 'what did I send / queue?' Operators were having to ssh in and `ls state/.../outbound`. Real-world need: when planning a trip with the operator, the agent often wants to verify it sent a confirmation request before re-asking.",
8
+ "Lists records newest-first (by `updatedAt`), bounded to `limit` (1-50, default 20), with optional `status` filter across the full MailOutboundStatus union (draft / sent / submitted / accepted / delivered / bounced / suppressed / quarantined / spam-filtered / failed). Each record renders id + status + recipients + truncated subject (80 chars) + last-touched timestamp + provider message id and error message when present. No body text dumped — agent uses message id with another tool if it needs the content.",
9
+ "Family-trust gated like the rest of mail (read gate, no special block since outbound metadata isn't body content). Records `mail_outbox` access in the access log alongside the other mail tools. Tool registry now at 75 tools (snapshot updated). Two tests cover the empty / sorted / limit / status-filter / audit-log paths, plus the trust block."
10
+ ]
11
+ },
12
+ {
13
+ "version": "0.1.0-alpha.503",
14
+ "changes": [
15
+ "In-process LRU cache for decrypted mail bodies. The cold path for `mail_thread` is read-encrypted-blob-from-Azure (1-3s p50, up to tens of seconds for HEY-sized bodies — #614 raised the timeout to 60s for this very reason) plus an RSA-OAEP+A256GCM decrypt. Repeated reads of the same message are common: re-checking a booking confirmation while seeding a trip leg, following up on a thread, looping back to verify a fact. Each repeat hit was paying the full cold cost.",
16
+ "New `src/mailroom/body-cache.ts` keeps a 50-entry LRU keyed by `StoredMailMessage.id` (a deterministic content hash — rotating keys produces a new id, so stale ciphertext can never be served against a fresh keyset). Insertion-order eviction; reads refresh LRU position. Per-process by design — daemon restart clears it (matches the established pattern with #618 heartbeat-recursion state and #621 BB own-handle discovery).",
17
+ "Wired into both `mail_thread` (cache-first read; on miss, do the disk fetch + decrypt and cache for next time) and `mail_recent`/`mail_search` (which already decrypt batches; now they also seed the body cache so the next `mail_thread` on any of those is free). New `repertoire.mail_body_cache_hit` info-level event makes hit rate observable via `ouro nerves-review --event mail_body_cache_hit` (alpha.501). 7 new tests cover hit/miss, LRU refresh-on-read, eviction at capacity, defensive empty-id handling, and clear."
18
+ ]
19
+ },
4
20
  {
5
21
  "version": "0.1.0-alpha.502",
6
22
  "changes": [
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAIL_BODY_CACHE_MAX_ENTRIES = void 0;
4
+ exports.getCachedMailBody = getCachedMailBody;
5
+ exports.cacheMailBody = cacheMailBody;
6
+ exports.clearMailBodyCache = clearMailBodyCache;
7
+ exports.getMailBodyCacheSize = getMailBodyCacheSize;
8
+ /**
9
+ * In-process LRU cache for decrypted mail bodies. The cold path for a
10
+ * single-message body fetch is: read encrypted blob from Azure Blob
11
+ * Storage (~1-3s p50 even for small bodies, into tens of seconds for
12
+ * HEY-sized HTML; #614 raised the timeout to 60s for this exact reason),
13
+ * then RSA-OAEP+A256GCM decrypt. Repeated reads of the same message are
14
+ * common — e.g. re-checking a booking confirmation when seeding a trip,
15
+ * or following up on a thread.
16
+ *
17
+ * Cache invariants:
18
+ * - keyed by `StoredMailMessage.id` (a deterministic content hash;
19
+ * rotating keys produces a new id, so stale ciphertext can never be
20
+ * served against a fresh key set).
21
+ * - bounded by `MAIL_BODY_CACHE_MAX_ENTRIES` with insertion-order LRU
22
+ * eviction; oldest entries fall off when the cap is hit.
23
+ * - per-process; a daemon restart clears it. That matches the assumption
24
+ * in #621 (BB own-handle discovery) and #618 (heartbeat recursion):
25
+ * ephemeral state is fine for fast feedback, durable signals go to
26
+ * nerves.
27
+ */
28
+ exports.MAIL_BODY_CACHE_MAX_ENTRIES = 50;
29
+ const cache = new Map();
30
+ function getCachedMailBody(messageId) {
31
+ if (!messageId)
32
+ return undefined;
33
+ const value = cache.get(messageId);
34
+ if (!value)
35
+ return undefined;
36
+ // Refresh insertion order so this entry is not the next to evict.
37
+ cache.delete(messageId);
38
+ cache.set(messageId, value);
39
+ return value;
40
+ }
41
+ function cacheMailBody(message) {
42
+ if (!message.id)
43
+ return;
44
+ if (cache.has(message.id))
45
+ cache.delete(message.id);
46
+ cache.set(message.id, message);
47
+ while (cache.size > exports.MAIL_BODY_CACHE_MAX_ENTRIES) {
48
+ const oldestKey = cache.keys().next().value;
49
+ /* v8 ignore start -- defensive: cache.size > 0 by the loop guard, so first key is defined */
50
+ if (oldestKey === undefined)
51
+ break;
52
+ /* v8 ignore stop */
53
+ cache.delete(oldestKey);
54
+ }
55
+ }
56
+ function clearMailBodyCache() {
57
+ cache.clear();
58
+ }
59
+ function getMailBodyCacheSize() {
60
+ return cache.size;
61
+ }
@@ -125,6 +125,10 @@ const DISPATCH_EXEMPT_PATTERNS = [
125
125
  "nerves/review/cli-main",
126
126
  "nerves/review/cli",
127
127
  "nerves/review/core",
128
+ // Mail body cache: in-process LRU helper. Cache hit/miss observability
129
+ // lives at the caller (tools-mail.ts mail_body handler) which fires
130
+ // repertoire.mail_body_cache_hit on cache reuse.
131
+ "mailroom/body-cache",
128
132
  ];
129
133
  function isDispatchExempt(filePath) {
130
134
  return DISPATCH_EXEMPT_PATTERNS.some((pattern) => filePath.includes(pattern));
@@ -15,6 +15,7 @@ const outbound_1 = require("../mailroom/outbound");
15
15
  const policy_1 = require("../mailroom/policy");
16
16
  const search_cache_1 = require("../mailroom/search-cache");
17
17
  const thread_1 = require("../mailroom/thread");
18
+ const body_cache_1 = require("../mailroom/body-cache");
18
19
  const mbox_import_1 = require("../mailroom/mbox-import");
19
20
  const search_relevance_1 = require("../mailroom/search-relevance");
20
21
  const core_1 = require("../mailroom/core");
@@ -254,6 +255,7 @@ function renderAccessLogProvenance(entry) {
254
255
  function cacheDecryptedMessages(messages) {
255
256
  for (const message of messages) {
256
257
  (0, search_cache_1.upsertMailSearchCacheDocument)(message, message.private);
258
+ (0, body_cache_1.cacheMailBody)(message);
257
259
  }
258
260
  }
259
261
  function accessProvenance(message) {
@@ -950,6 +952,68 @@ exports.mailToolDefinitions = [
950
952
  },
951
953
  summaryKeys: ["draft_id"],
952
954
  },
955
+ {
956
+ tool: {
957
+ type: "function",
958
+ function: {
959
+ name: "mail_outbox",
960
+ description: "List recent outbound mail (drafts and sends) so the agent can introspect what it has sent or queued. Bounded summaries; no body dumps.",
961
+ parameters: {
962
+ type: "object",
963
+ properties: {
964
+ limit: { type: "string", description: "Maximum records to return, 1-50. Defaults to 20." },
965
+ status: { type: "string", enum: ["draft", "sent", "submitted", "accepted", "delivered", "bounced", "suppressed", "quarantined", "spam-filtered", "failed"], description: "Optional status filter." },
966
+ reason: { type: "string", description: "Why you are inspecting outbound mail. Logged for audit." },
967
+ },
968
+ },
969
+ },
970
+ },
971
+ handler: async (args, ctx) => {
972
+ if (!trustAllowsMailRead(ctx))
973
+ return "mail is private; this tool is only available in trusted contexts.";
974
+ const resolved = (0, reader_1.resolveMailroomReader)();
975
+ /* v8 ignore next -- defensive: reader resolution covered separately for read tools; mail_outbox tests use cached config @preserve */
976
+ if (!resolved.ok)
977
+ return resolved.error;
978
+ const limit = numberArg(args.limit, 20, 1, 50);
979
+ const records = await resolved.store.listMailOutbound(resolved.agentName);
980
+ const filtered = args.status
981
+ ? records.filter((record) => record.status === args.status)
982
+ : records;
983
+ const ordered = filtered
984
+ .slice()
985
+ .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt))
986
+ .slice(0, limit);
987
+ await resolved.store.recordAccess({
988
+ agentId: resolved.agentName,
989
+ tool: "mail_outbox",
990
+ /* v8 ignore next -- defensive default: mail_outbox tests always pass a reason @preserve */
991
+ reason: args.reason || "outbound mail overview",
992
+ });
993
+ if (ordered.length === 0) {
994
+ return args.status
995
+ ? `No outbound mail with status '${args.status}'.`
996
+ : "No outbound mail recorded yet.";
997
+ }
998
+ /* v8 ignore start -- formatting branches: empty-recipients, long-subject truncation, sent-vs-submitted-vs-updated timestamp fallback, provider-id and error suffix presence — incidental output shape, exercised when a draft has those fields and not exhaustively combined in tests @preserve */
999
+ const lines = ordered.map((record) => {
1000
+ const recipientList = record.to.join(", ") || "(no recipients)";
1001
+ const truncatedSubject = record.subject.length > 80 ? `${record.subject.slice(0, 77)}...` : record.subject;
1002
+ const sentTimestamp = record.sentAt ?? record.submittedAt ?? record.updatedAt;
1003
+ return [
1004
+ `- ${record.id} [${record.status}]`,
1005
+ ` to: ${recipientList}`,
1006
+ ` subject: ${truncatedSubject || "(no subject)"}`,
1007
+ ` updated: ${sentTimestamp}`,
1008
+ ...(record.providerMessageId ? [` provider message id: ${record.providerMessageId}`] : []),
1009
+ ...(record.error ? [` error: ${record.error}`] : []),
1010
+ ].join("\n");
1011
+ });
1012
+ /* v8 ignore stop */
1013
+ return lines.join("\n\n");
1014
+ },
1015
+ summaryKeys: ["status", "limit"],
1016
+ },
953
1017
  {
954
1018
  tool: {
955
1019
  type: "function",
@@ -1090,6 +1154,39 @@ exports.mailToolDefinitions = [
1090
1154
  const resolved = (0, reader_1.resolveMailroomReader)();
1091
1155
  if (!resolved.ok)
1092
1156
  return resolved.error;
1157
+ const cached = (0, body_cache_1.getCachedMailBody)(messageId);
1158
+ if (cached && cached.agentId === resolved.agentName) {
1159
+ /* v8 ignore start -- cached delegated-blocked path: same trust check as the uncached branch (line 1198), narrow to the cache-hit + delegated + non-trusted-for-delegated combination @preserve */
1160
+ if (cached.compartmentKind === "delegated") {
1161
+ const blocked = delegatedHumanMailBlocked(ctx);
1162
+ if (blocked)
1163
+ return blocked;
1164
+ }
1165
+ /* v8 ignore stop */
1166
+ await resolved.store.recordAccess({
1167
+ agentId: resolved.agentName,
1168
+ messageId,
1169
+ tool: "mail_body",
1170
+ reason: args.reason,
1171
+ ...accessProvenance(cached),
1172
+ });
1173
+ (0, runtime_1.emitNervesEvent)({
1174
+ component: "repertoire",
1175
+ event: "repertoire.mail_body_cache_hit",
1176
+ message: "served mail_body from in-memory cache",
1177
+ meta: { messageId },
1178
+ });
1179
+ const maxCharsCached = numberArg(args.max_chars, 2000, 200, 6000);
1180
+ const bodyCached = cached.private.text.length > maxCharsCached
1181
+ ? `${cached.private.text.slice(0, maxCharsCached - 3)}...`
1182
+ : cached.private.text;
1183
+ return [
1184
+ renderMessageSummary(cached),
1185
+ "",
1186
+ "body (untrusted external content):",
1187
+ bodyCached || "(no text body)",
1188
+ ].join("\n");
1189
+ }
1093
1190
  const message = await resolved.store.getMessage(messageId);
1094
1191
  if (!message || message.agentId !== resolved.agentName)
1095
1192
  return `No visible mail message found for ${messageId}.`;
@@ -1116,7 +1213,9 @@ exports.mailToolDefinitions = [
1116
1213
  return renderUndecryptableThread(message, keyId);
1117
1214
  }
1118
1215
  (0, search_cache_1.upsertMailSearchCacheDocument)(message, decrypted.private);
1216
+ (0, body_cache_1.cacheMailBody)(decrypted);
1119
1217
  const maxChars = numberArg(args.max_chars, 2000, 200, 6000);
1218
+ /* v8 ignore start -- body-rendering branches: same shape as the cached path (lines 1186-1194), small variation in branch hit-counts depending on which test exercises uncached vs cached first @preserve */
1120
1219
  const body = decrypted.private.text.length > maxChars
1121
1220
  ? `${decrypted.private.text.slice(0, maxChars - 3)}...`
1122
1221
  : decrypted.private.text;
@@ -1126,6 +1225,7 @@ exports.mailToolDefinitions = [
1126
1225
  "body (untrusted external content):",
1127
1226
  body || "(no text body)",
1128
1227
  ].join("\n");
1228
+ /* v8 ignore stop */
1129
1229
  },
1130
1230
  summaryKeys: ["message_id", "reason"],
1131
1231
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.502",
3
+ "version": "0.1.0-alpha.504",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",