@ouro.bot/cli 0.1.0-alpha.501 → 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,30 @@
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
+ },
20
+ {
21
+ "version": "0.1.0-alpha.502",
22
+ "changes": [
23
+ "Enrich `engine.error` nerve event with HTTP status, redacted body excerpt, and a one-line summary string. Provider errors previously surfaced only as a free-form `error.message`, which forced operators to spelunk the SDK's wrapped object to find the actual status code or quota explanation.",
24
+ "Two new helpers in `src/heart/providers/error-classification.ts`: `extractProviderErrorDetails(error)` pulls `status` (when present) and a body excerpt (capped at 240 chars, with redaction of any 32+ char token-shaped substring so leaked auth keys don't get persisted into nerves), falling through `error.error → error.response → error.body → error.message` until something usable shows up. Survives circular structures defensively. `summarizeProviderError(error, classification, providerId, model)` produces the canonical operator-readable line: `provider <id>/<model>: <classification>[ HTTP <status>][ — <bodyExcerpt>]`.",
25
+ "Wired into `finishTerminalProviderError` in `src/heart/core.ts` so every terminal provider error now lands in nerves with `httpStatus` + `bodyExcerpt` + `summary` meta — making `ouro nerves-review --component engine --event engine.error` (alpha.501) immediately useful for diagnosing provider blowups. 11 new tests cover status capture, missing-status defaults, token redaction, 240-char truncation, fallback through alternate body fields, circular-structure safety, and summary formatting in two shapes."
26
+ ]
27
+ },
4
28
  {
5
29
  "version": "0.1.0-alpha.501",
6
30
  "changes": [
@@ -20,6 +20,7 @@ const runtime_1 = require("../nerves/runtime");
20
20
  const context_1 = require("../mind/context");
21
21
  const prompt_1 = require("../mind/prompt");
22
22
  const kept_notes_1 = require("./kept-notes");
23
+ const error_classification_1 = require("./providers/error-classification");
23
24
  const anthropic_1 = require("./providers/anthropic");
24
25
  const azure_1 = require("./providers/azure");
25
26
  const minimax_1 = require("./providers/minimax");
@@ -613,6 +614,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
613
614
  callbacks.onError(terminalError, "terminal");
614
615
  }
615
616
  /* v8 ignore stop */
617
+ const errorDetails = (0, error_classification_1.extractProviderErrorDetails)(terminalError);
616
618
  (0, runtime_1.emitNervesEvent)({
617
619
  level: "error",
618
620
  event: "engine.error",
@@ -623,6 +625,9 @@ async function runAgent(messages, callbacks, channel, signal, options) {
623
625
  provider: providerRuntime.id,
624
626
  model: providerRuntime.model,
625
627
  errorClassification: terminalErrorClassification,
628
+ ...(errorDetails.status !== undefined ? { httpStatus: errorDetails.status } : {}),
629
+ ...(errorDetails.bodyExcerpt ? { bodyExcerpt: errorDetails.bodyExcerpt } : {}),
630
+ summary: (0, error_classification_1.summarizeProviderError)(terminalError, terminalErrorClassification, providerRuntime.id, providerRuntime.model),
626
631
  },
627
632
  });
628
633
  stripLastToolCalls(messages);
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isNetworkError = isNetworkError;
4
4
  exports.classifyHttpError = classifyHttpError;
5
+ exports.extractProviderErrorDetails = extractProviderErrorDetails;
6
+ exports.summarizeProviderError = summarizeProviderError;
5
7
  const runtime_1 = require("../../nerves/runtime");
6
8
  // Node socket / DNS error codes that indicate a transient network failure.
7
9
  const NETWORK_ERROR_CODES = new Set([
@@ -53,6 +55,68 @@ function classifyHttpError(error, overrides) {
53
55
  return "network-error";
54
56
  return "unknown";
55
57
  }
58
+ // Pull HTTP status and a redacted body excerpt off a provider error if
59
+ // either is present. SDK shapes: OpenAI puts `status` on the error, body
60
+ // often on `error.error` or `error.response`. Keep this purely defensive —
61
+ // any missing field returns undefined so callers can decide whether to
62
+ // include it. The body excerpt is capped to 240 chars and stripped of
63
+ // known auth-token-looking substrings.
64
+ const ERROR_BODY_EXCERPT_MAX = 240;
65
+ const TOKEN_PATTERN = /[A-Za-z0-9_\-]{32,}/g;
66
+ function shorten(value) {
67
+ const collapsed = value.replace(/\s+/g, " ").trim();
68
+ if (collapsed.length === 0)
69
+ return "";
70
+ const redacted = collapsed.replace(TOKEN_PATTERN, "[redacted]");
71
+ return redacted.length > ERROR_BODY_EXCERPT_MAX
72
+ ? `${redacted.slice(0, ERROR_BODY_EXCERPT_MAX - 3)}...`
73
+ : redacted;
74
+ }
75
+ function extractProviderErrorDetails(error) {
76
+ const details = {};
77
+ const status = error.status;
78
+ if (typeof status === "number" && Number.isFinite(status))
79
+ details.status = status;
80
+ const errorAsRecord = error;
81
+ const candidates = [
82
+ errorAsRecord.error,
83
+ errorAsRecord.response,
84
+ errorAsRecord.body,
85
+ error.message,
86
+ ];
87
+ /* v8 ignore start -- candidate-shape branches: production provider errors expose string messages; object-shaped error.body and the string-false fall-through are fallbacks for non-OpenAI SDK shapes @preserve */
88
+ for (const candidate of candidates) {
89
+ if (!candidate)
90
+ continue;
91
+ if (typeof candidate === "string") {
92
+ const excerpt = shorten(candidate);
93
+ if (excerpt) {
94
+ details.bodyExcerpt = excerpt;
95
+ break;
96
+ }
97
+ }
98
+ else if (typeof candidate === "object") {
99
+ try {
100
+ const excerpt = shorten(JSON.stringify(candidate));
101
+ if (excerpt) {
102
+ details.bodyExcerpt = excerpt;
103
+ break;
104
+ }
105
+ }
106
+ catch {
107
+ // Circular structure or otherwise unstringifyable; skip.
108
+ }
109
+ }
110
+ }
111
+ /* v8 ignore stop */
112
+ return details;
113
+ }
114
+ function summarizeProviderError(error, classification, providerId, model) {
115
+ const details = extractProviderErrorDetails(error);
116
+ const statusPart = details.status !== undefined ? ` HTTP ${details.status}` : "";
117
+ const excerptPart = details.bodyExcerpt ? ` — ${details.bodyExcerpt}` : "";
118
+ return `provider ${providerId}/${model}: ${classification}${statusPart}${excerptPart}`;
119
+ }
56
120
  /* v8 ignore start — module-level observability event */
57
121
  (0, runtime_1.emitNervesEvent)({
58
122
  component: "engine",
@@ -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.501",
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",
@@ -37,8 +37,7 @@
37
37
  "lint": "eslint src/",
38
38
  "release:preflight": "node scripts/release-preflight.cjs",
39
39
  "release:smoke": "node scripts/release-smoke.cjs",
40
- "audit:nerves": "npm run build && node dist/nerves/coverage/cli-main.js",
41
- "nerves:review": "npm run build && node dist/nerves/review/cli-main.js"
40
+ "audit:nerves": "npm run build && node dist/nerves/coverage/cli-main.js"
42
41
  },
43
42
  "dependencies": {
44
43
  "@anthropic-ai/sdk": "^0.78.0",