@ouro.bot/cli 0.1.0-alpha.535 → 0.1.0-alpha.537

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,21 @@
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.537",
6
+ "changes": [
7
+ "Agents now render a trip-ledger truth section when structured travel plans exist, making the trip ledger outrank stale friend notes, old handoffs, and memory for itinerary and gap questions.",
8
+ "`trip_calendar` now explicitly tells agents to use the ledger before answering current itinerary, travel gap, or what-changed questions so mail-backed travel facts stay reachable in live conversations."
9
+ ]
10
+ },
11
+ {
12
+ "version": "0.1.0-alpha.536",
13
+ "changes": [
14
+ "Delegated hosted mail search now tracks durable coverage records and refuses to treat stale cache misses as absence proof, with `mail_index_refresh` repairing projection-incomplete cache entries before coverage is considered current.",
15
+ "Mail ingestion, search caching, and body rendering now preserve searchable text from HTML-only messages, preventing travel facts from disappearing when an email lacks a plain-text body.",
16
+ "MCP send-message and daemon socket commands now fail with bounded timeout errors instead of hanging forever, and trip ledgers can be rendered as chronological calendars with evidence-backed `trip_calendar` output."
17
+ ]
18
+ },
4
19
  {
5
20
  "version": "0.1.0-alpha.535",
6
21
  "changes": [
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.DEFAULT_DAEMON_SOCKET_PATH = void 0;
36
+ exports.DEFAULT_DAEMON_COMMAND_TIMEOUT_MS = exports.DEFAULT_DAEMON_SOCKET_PATH = void 0;
37
37
  exports.__bypassVitestGuardForTests = __bypassVitestGuardForTests;
38
38
  exports.sendDaemonCommand = sendDaemonCommand;
39
39
  exports.checkDaemonSocketAlive = checkDaemonSocketAlive;
@@ -42,6 +42,7 @@ const fs = __importStar(require("fs"));
42
42
  const net = __importStar(require("net"));
43
43
  const runtime_1 = require("../../nerves/runtime");
44
44
  exports.DEFAULT_DAEMON_SOCKET_PATH = "/tmp/ouroboros-daemon.sock";
45
+ exports.DEFAULT_DAEMON_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
45
46
  /**
46
47
  * Defense-in-depth: detect if we're running under vitest. Tests that forget
47
48
  * to `vi.mock("../../heart/daemon/socket-client")` would otherwise leak real
@@ -106,7 +107,7 @@ function shouldSuppressSocketCall(socketPath) {
106
107
  return true;
107
108
  return globalThis[BYPASS_KEY] !== true;
108
109
  }
109
- function sendDaemonCommand(socketPath, command) {
110
+ function sendDaemonCommand(socketPath, command, options = {}) {
110
111
  if (shouldSuppressSocketCall(socketPath)) {
111
112
  (0, runtime_1.emitNervesEvent)({
112
113
  level: "warn",
@@ -126,6 +127,41 @@ function sendDaemonCommand(socketPath, command) {
126
127
  return new Promise((resolve, reject) => {
127
128
  const client = net.createConnection(socketPath);
128
129
  let raw = "";
130
+ let settled = false;
131
+ const timeoutMs = options.timeoutMs ?? exports.DEFAULT_DAEMON_COMMAND_TIMEOUT_MS;
132
+ const resolveOnce = (response) => {
133
+ /* v8 ignore next -- duplicate settlement guard; callers also guard event handlers before reaching this helper @preserve */
134
+ if (settled)
135
+ return;
136
+ settled = true;
137
+ resolve(response);
138
+ };
139
+ const rejectOnce = (error) => {
140
+ /* v8 ignore next -- duplicate timeout/error races should not re-reject the command promise @preserve */
141
+ if (settled)
142
+ return;
143
+ settled = true;
144
+ reject(error);
145
+ };
146
+ if ("setTimeout" in client && typeof client.setTimeout === "function") {
147
+ client.setTimeout(timeoutMs, () => {
148
+ const error = new Error(`Daemon command ${command.kind} timed out after ${timeoutMs}ms waiting for a response.`);
149
+ (0, runtime_1.emitNervesEvent)({
150
+ level: "error",
151
+ component: "daemon",
152
+ event: "daemon.socket_command_timeout",
153
+ message: "daemon socket command timed out",
154
+ meta: {
155
+ socketPath,
156
+ kind: command.kind,
157
+ timeoutMs,
158
+ error: error.message,
159
+ },
160
+ });
161
+ client.destroy();
162
+ rejectOnce(error);
163
+ });
164
+ }
129
165
  client.on("connect", () => {
130
166
  // Write the command + newline delimiter. DO NOT call client.end()
131
167
  // afterwards — the server closes the connection once it has written
@@ -149,6 +185,9 @@ function sendDaemonCommand(socketPath, command) {
149
185
  raw += chunk.toString("utf-8");
150
186
  });
151
187
  client.on("error", (error) => {
188
+ /* v8 ignore next -- duplicate post-settlement socket errors are ignored defensively @preserve */
189
+ if (settled)
190
+ return;
152
191
  (0, runtime_1.emitNervesEvent)({
153
192
  level: "error",
154
193
  component: "daemon",
@@ -160,9 +199,12 @@ function sendDaemonCommand(socketPath, command) {
160
199
  error: error.message,
161
200
  },
162
201
  });
163
- reject(error);
202
+ rejectOnce(error);
164
203
  });
165
204
  client.on("end", () => {
205
+ /* v8 ignore next -- duplicate post-settlement socket end events are ignored defensively @preserve */
206
+ if (settled)
207
+ return;
166
208
  const trimmed = raw.trim();
167
209
  if (trimmed.length === 0 && command.kind === "daemon.stop") {
168
210
  (0, runtime_1.emitNervesEvent)({
@@ -171,7 +213,7 @@ function sendDaemonCommand(socketPath, command) {
171
213
  message: "daemon socket command completed",
172
214
  meta: { socketPath, kind: command.kind, ok: true },
173
215
  });
174
- resolve({ ok: true, message: "daemon stopped" });
216
+ resolveOnce({ ok: true, message: "daemon stopped" });
175
217
  return;
176
218
  }
177
219
  if (trimmed.length === 0) {
@@ -187,7 +229,7 @@ function sendDaemonCommand(socketPath, command) {
187
229
  error: error.message,
188
230
  },
189
231
  });
190
- reject(error);
232
+ rejectOnce(error);
191
233
  return;
192
234
  }
193
235
  try {
@@ -202,7 +244,7 @@ function sendDaemonCommand(socketPath, command) {
202
244
  ok: parsed.ok,
203
245
  },
204
246
  });
205
- resolve(parsed);
247
+ resolveOnce(parsed);
206
248
  }
207
249
  catch (error) {
208
250
  (0, runtime_1.emitNervesEvent)({
@@ -216,7 +258,7 @@ function sendDaemonCommand(socketPath, command) {
216
258
  error: error instanceof Error ? error.message : String(error),
217
259
  },
218
260
  });
219
- reject(error);
261
+ rejectOnce(error);
220
262
  }
221
263
  });
222
264
  });
@@ -33,8 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports._senseTurnRetryDelays = exports.SENSE_TURN_RETRY_DELAYS_MS = exports.SENSE_TURN_MAX_RETRIES = void 0;
36
+ exports._senseTurnCommandTimeoutMs = exports._senseTurnRetryDelays = exports.SENSE_TURN_COMMAND_TIMEOUT_MS = exports.SENSE_TURN_RETRY_DELAYS_MS = exports.SENSE_TURN_MAX_RETRIES = void 0;
37
37
  exports._setSenseTurnRetryDelays = _setSenseTurnRetryDelays;
38
+ exports._setSenseTurnCommandTimeoutMs = _setSenseTurnCommandTimeoutMs;
38
39
  exports.createMcpServer = createMcpServer;
39
40
  exports.getToolSchemas = getToolSchemas;
40
41
  const socket_client_1 = require("../daemon/socket-client");
@@ -44,9 +45,38 @@ const session_id_resolver_1 = require("../daemon/session-id-resolver");
44
45
  const pending_1 = require("../../mind/pending");
45
46
  exports.SENSE_TURN_MAX_RETRIES = 3;
46
47
  exports.SENSE_TURN_RETRY_DELAYS_MS = [1000, 2000, 4000];
48
+ exports.SENSE_TURN_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
47
49
  // Allow test override
48
50
  exports._senseTurnRetryDelays = exports.SENSE_TURN_RETRY_DELAYS_MS;
49
51
  function _setSenseTurnRetryDelays(delays) { exports._senseTurnRetryDelays = delays; }
52
+ exports._senseTurnCommandTimeoutMs = exports.SENSE_TURN_COMMAND_TIMEOUT_MS;
53
+ function _setSenseTurnCommandTimeoutMs(timeoutMs) { exports._senseTurnCommandTimeoutMs = timeoutMs; }
54
+ async function withSenseTurnTimeout(promise, timeoutMs, command) {
55
+ let timer = null;
56
+ try {
57
+ return await Promise.race([
58
+ promise,
59
+ new Promise((_, reject) => {
60
+ timer = setTimeout(() => {
61
+ const error = new Error(`MCP send_message to ${command.agent} timed out after ${timeoutMs}ms waiting for daemon response; command status is unknown.`);
62
+ (0, runtime_1.emitNervesEvent)({
63
+ level: "error",
64
+ component: "daemon",
65
+ event: "daemon.mcp_sense_turn_timeout",
66
+ message: "MCP senseTurn timed out waiting for daemon response",
67
+ meta: { agent: command.agent, friendId: command.friendId, sessionKey: command.sessionKey, timeoutMs },
68
+ });
69
+ reject(error);
70
+ }, timeoutMs);
71
+ }),
72
+ ]);
73
+ }
74
+ finally {
75
+ /* v8 ignore next -- Promise.race installs the timer synchronously; null is only a defensive cleanup guard @preserve */
76
+ if (timer)
77
+ clearTimeout(timer);
78
+ }
79
+ }
50
80
  /**
51
81
  * Send a senseTurn command to the daemon with retry logic.
52
82
  * Retries on transient failures: empty response (daemon mid-restart),
@@ -57,7 +87,7 @@ async function sendSenseTurnWithRetry(socketPath, command) {
57
87
  /* v8 ignore start -- retry loop: functionally tested via mcp-send-message retry tests @preserve */
58
88
  for (let attempt = 0; attempt <= exports.SENSE_TURN_MAX_RETRIES; attempt++) {
59
89
  try {
60
- const response = await (0, socket_client_1.sendDaemonCommand)(socketPath, command);
90
+ const response = await withSenseTurnTimeout((0, socket_client_1.sendDaemonCommand)(socketPath, command), exports._senseTurnCommandTimeoutMs, command);
61
91
  return response;
62
92
  }
63
93
  catch (error) {
@@ -83,6 +83,17 @@ function isRetryableBlobDownloadError(error) {
83
83
  message.includes("etimedout") ||
84
84
  message.includes("socket closed");
85
85
  }
86
+ function isBlobNotFoundError(error) {
87
+ const maybeStatus = typeof error === "object" && error !== null && "statusCode" in error
88
+ ? error.statusCode
89
+ : undefined;
90
+ if (maybeStatus === 404)
91
+ return true;
92
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
93
+ return message.includes("blobnotfound") ||
94
+ message.includes("the specified blob does not exist") ||
95
+ message.includes("missing blob");
96
+ }
86
97
  async function downloadJson(blob, timeoutMs) {
87
98
  if (!await blob.exists())
88
99
  return null;
@@ -102,6 +113,25 @@ async function downloadJson(blob, timeoutMs) {
102
113
  }
103
114
  throw normalizeBlobOperationError("download", blob, timeoutMs, lastError);
104
115
  }
116
+ async function downloadIndexedJson(blob, timeoutMs) {
117
+ let lastError = null;
118
+ for (let attempt = 1; attempt <= DEFAULT_BLOB_DOWNLOAD_ATTEMPTS; attempt += 1) {
119
+ try {
120
+ const buffer = await withBlobOperationTimeout(timeoutMs, (abortSignal) => {
121
+ return blob.downloadToBuffer(undefined, undefined, { abortSignal });
122
+ });
123
+ return JSON.parse(buffer.toString("utf-8"));
124
+ }
125
+ catch (error) {
126
+ if (isBlobNotFoundError(error))
127
+ return null;
128
+ lastError = error;
129
+ if (attempt >= DEFAULT_BLOB_DOWNLOAD_ATTEMPTS || !isRetryableBlobDownloadError(error))
130
+ break;
131
+ }
132
+ }
133
+ throw normalizeBlobOperationError("download", blob, timeoutMs, lastError);
134
+ }
105
135
  async function uploadJson(blob, value, timeoutMs) {
106
136
  try {
107
137
  await withBlobOperationTimeout(timeoutMs, (abortSignal) => {
@@ -275,20 +305,35 @@ class AzureBlobMailroomStore {
275
305
  await Promise.all(Array.from({ length: Math.min(MESSAGE_LIST_SCAN_CONCURRENCY, Math.max(messageBlobNames.length, 1)) }, async () => worker()));
276
306
  return applyOptionalLimit(matches.sort(compareNewestFirst), filters.limit);
277
307
  }
278
- async listMessagesFromIndexes(filters) {
279
- const messageIds = [];
308
+ async listMessageIndexRecords(filters) {
309
+ await this.ensureContainer();
310
+ const records = [];
280
311
  let sawIndex = false;
281
312
  for await (const item of this.container.listBlobsFlat({ prefix: messageIndexPrefix(filters.agentId) })) {
282
313
  sawIndex = true;
283
314
  const parsed = parseMessageIndexBlobName(item.name);
284
315
  if (!parsed || !messageMatchesFilters(parsed, filters))
285
316
  continue;
286
- messageIds.push(parsed.id);
287
- if (typeof filters.limit === "number" && messageIds.length >= filters.limit)
317
+ records.push(parsed);
318
+ if (typeof filters.limit === "number" && records.length >= filters.limit)
288
319
  break;
289
320
  }
290
321
  if (!sawIndex)
291
322
  return null;
323
+ const sorted = records.sort((left, right) => Date.parse(right.receivedAt) - Date.parse(left.receivedAt));
324
+ (0, runtime_1.emitNervesEvent)({
325
+ component: "senses",
326
+ event: "senses.mail_blob_store_message_indexes_listed",
327
+ message: "azure blob mailroom store listed message indexes",
328
+ meta: { agentId: filters.agentId, count: sorted.length },
329
+ });
330
+ return applyOptionalLimit(sorted, filters.limit);
331
+ }
332
+ async listMessagesFromIndexes(filters) {
333
+ const records = await this.listMessageIndexRecords(filters);
334
+ if (records === null)
335
+ return null;
336
+ const messageIds = records.map((record) => record.id);
292
337
  const messages = (await mapWithConcurrency(messageIds, this.messageFetchConcurrency, async (id) => {
293
338
  return downloadJson(this.messageBlob(id), this.blobOperationTimeoutMs);
294
339
  }))
@@ -404,6 +449,17 @@ class AzureBlobMailroomStore {
404
449
  });
405
450
  return message;
406
451
  }
452
+ async getIndexedMessageById(id) {
453
+ await this.ensureContainer();
454
+ const message = await downloadIndexedJson(this.messageBlob(id), this.blobOperationTimeoutMs);
455
+ (0, runtime_1.emitNervesEvent)({
456
+ component: "senses",
457
+ event: "senses.mail_blob_store_indexed_message_read",
458
+ message: "azure blob mailroom store read message from known index",
459
+ meta: { id, found: message !== null },
460
+ });
461
+ return message;
462
+ }
407
463
  async listMessages(filters) {
408
464
  await this.ensureContainer();
409
465
  let filtered = await this.listMessagesFromIndexes(filters);
@@ -46,6 +46,8 @@ exports.encryptJsonForMailKey = encryptJsonForMailKey;
46
46
  exports.decryptMailJson = decryptMailJson;
47
47
  exports.resolveMailAddress = resolveMailAddress;
48
48
  exports.describeMailProvenance = describeMailProvenance;
49
+ exports.htmlMailBodyToText = htmlMailBodyToText;
50
+ exports.privateMailEnvelopeReadableText = privateMailEnvelopeReadableText;
49
51
  exports.buildStoredMailMessage = buildStoredMailMessage;
50
52
  exports.decryptStoredMailMessage = decryptStoredMailMessage;
51
53
  exports.provisionMailboxRegistry = provisionMailboxRegistry;
@@ -396,10 +398,56 @@ function candidateSender(input) {
396
398
  function normalizedIngestProvenance(input) {
397
399
  return input ?? { schemaVersion: 1, kind: "smtp" };
398
400
  }
401
+ function decodeHtmlEntity(entity) {
402
+ const named = {
403
+ amp: "&",
404
+ apos: "'",
405
+ gt: ">",
406
+ lt: "<",
407
+ nbsp: " ",
408
+ quot: "\"",
409
+ };
410
+ const lowered = entity.toLowerCase();
411
+ if (named[lowered] !== undefined)
412
+ return named[lowered];
413
+ if (lowered.startsWith("#x")) {
414
+ const parsed = Number.parseInt(lowered.slice(2), 16);
415
+ return Number.isFinite(parsed) && parsed >= 0 && parsed <= 0x10ffff ? String.fromCodePoint(parsed) : `&${entity};`;
416
+ }
417
+ if (lowered.startsWith("#")) {
418
+ const parsed = Number.parseInt(lowered.slice(1), 10);
419
+ return Number.isFinite(parsed) && parsed >= 0 && parsed <= 0x10ffff ? String.fromCodePoint(parsed) : `&${entity};`;
420
+ }
421
+ return `&${entity};`;
422
+ }
423
+ function htmlMailBodyToText(html) {
424
+ return html
425
+ .replace(/<\s*(script|style|head|title|noscript)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, " ")
426
+ .replace(/<\s*br\s*\/?\s*>/gi, "\n")
427
+ .replace(/<\s*\/?\s*(address|article|aside|blockquote|body|caption|div|figcaption|figure|footer|h[1-6]|header|hr|li|main|nav|ol|p|pre|section|table|tbody|td|tfoot|th|thead|tr|ul)\b[^>]*>/gi, "\n")
428
+ .replace(/<[^>]+>/g, " ")
429
+ .replace(/&([a-zA-Z][a-zA-Z0-9]+|#[0-9]+|#x[0-9a-fA-F]+);/g, (_match, entity) => decodeHtmlEntity(entity))
430
+ .replace(/\r\n?/g, "\n")
431
+ .replace(/[ \t\f\v]+/g, " ")
432
+ .replace(/\n[ \t]+/g, "\n")
433
+ .replace(/[ \t]+\n/g, "\n")
434
+ .replace(/\n{3,}/g, "\n\n")
435
+ .trim();
436
+ }
437
+ function privateMailEnvelopeReadableText(privateEnvelope) {
438
+ if (privateEnvelope.text.trim().length > 0)
439
+ return privateEnvelope.text;
440
+ if (!privateEnvelope.html || privateEnvelope.html.trim().length === 0)
441
+ return "";
442
+ return htmlMailBodyToText(privateEnvelope.html);
443
+ }
399
444
  async function buildStoredMailMessage(input) {
400
445
  const parsed = await (0, mailparser_1.simpleParser)(input.rawMime);
401
446
  const id = messageStorageId(input.envelope, input.rawMime);
402
- const text = parsed.text ?? "";
447
+ const html = typeof parsed.html === "string" ? parsed.html : undefined;
448
+ const parsedText = parsed.text ?? "";
449
+ /* v8 ignore next -- mailparser body-shape ternary permutations are covered by plain-text, HTML-only, and empty-MIME tests; this line is a projection adapter, not policy. @preserve */
450
+ const text = parsedText.trim().length > 0 ? parsedText : html ? htmlMailBodyToText(html) : "";
403
451
  const inReplyTo = typeof parsed.inReplyTo === "string" && parsed.inReplyTo.trim().length > 0
404
452
  ? parsed.inReplyTo.trim()
405
453
  : undefined;
@@ -422,7 +470,7 @@ async function buildStoredMailMessage(input) {
422
470
  subject: parsed.subject ?? "",
423
471
  date: parsed.date?.toISOString(),
424
472
  text,
425
- html: typeof parsed.html === "string" ? parsed.html : undefined,
473
+ html,
426
474
  snippet: snippet(text || parsed.subject || "(no text body)"),
427
475
  attachments: parsed.attachments.map((attachment) => ({
428
476
  filename: attachment.filename ?? "(unnamed attachment)",
@@ -372,8 +372,9 @@ async function cacheMatchingMailSearchDocumentsFromMboxFile(input) {
372
372
  continue;
373
373
  (0, search_cache_1.upsertMailSearchCacheDocument)(message, privateEnvelope);
374
374
  matches.push(document);
375
- if (matches.length >= input.limit)
376
- break;
375
+ matches.sort((left, right) => (0, search_relevance_1.compareByRelevanceThenRecency)({ document: left, relevance: (0, search_relevance_1.scoreMailSearchDocument)(left, queryTerms) }, { document: right, relevance: (0, search_relevance_1.scoreMailSearchDocument)(right, queryTerms) }));
376
+ if (matches.length > input.limit)
377
+ matches.length = input.limit;
377
378
  }
378
379
  return matches
379
380
  .map((document) => ({ document, relevance: (0, search_relevance_1.scoreMailSearchDocument)(document, queryTerms) }))
@@ -220,7 +220,9 @@ function resolveMailroomReader(agentName = (0, identity_2.getAgentName)()) {
220
220
  }
221
221
  async function resolveMailroomReaderWithRefresh(agentName = (0, identity_2.getAgentName)()) {
222
222
  const resolved = resolveMailroomReader(agentName);
223
- if (resolved.ok || resolved.reason !== "auth-required" || !resolved.error.includes(" is unavailable")) {
223
+ if (resolved.ok
224
+ || resolved.reason !== "auth-required"
225
+ || (!resolved.error.includes(" is unavailable") && !resolved.error.includes(" is missing"))) {
224
226
  return resolved;
225
227
  }
226
228
  await (0, runtime_credentials_1.refreshRuntimeCredentialConfig)(agentName, { preserveCachedOnFailure: true }).catch(() => undefined);
@@ -33,17 +33,22 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.MAIL_SEARCH_TEXT_PROJECTION_VERSION = void 0;
36
37
  exports.buildMailSearchCacheDocument = buildMailSearchCacheDocument;
37
38
  exports.upsertMailSearchCacheDocument = upsertMailSearchCacheDocument;
38
39
  exports.syncMailSearchCacheMetadata = syncMailSearchCacheMetadata;
39
40
  exports.searchMailSearchCache = searchMailSearchCache;
41
+ exports.readMailSearchCoverageRecord = readMailSearchCoverageRecord;
42
+ exports.writeMailSearchCoverageRecord = writeMailSearchCoverageRecord;
40
43
  exports.resetMailSearchCacheForTests = resetMailSearchCacheForTests;
41
44
  const fs = __importStar(require("node:fs"));
42
45
  const path = __importStar(require("node:path"));
43
46
  const identity_1 = require("../heart/identity");
44
47
  const runtime_1 = require("../nerves/runtime");
48
+ const core_1 = require("./core");
45
49
  const search_relevance_1 = require("./search-relevance");
46
50
  const SEARCH_TEXT_EXCERPT_LIMIT = 16_384;
51
+ exports.MAIL_SEARCH_TEXT_PROJECTION_VERSION = 2;
47
52
  const cacheStates = new Map();
48
53
  function cacheDir(agentId) {
49
54
  return path.join((0, identity_1.getAgentRoot)(agentId), "state", "mail-search");
@@ -51,11 +56,29 @@ function cacheDir(agentId) {
51
56
  function cachePath(agentId, messageId) {
52
57
  return path.join(cacheDir(agentId), `${messageId}.json`);
53
58
  }
59
+ function coverageDir(agentId) {
60
+ return path.join(cacheDir(agentId), "coverage");
61
+ }
62
+ function normalizedCoverageKey(key) {
63
+ return {
64
+ agentId: key.agentId,
65
+ storeKind: key.storeKind,
66
+ ...(key.placement ? { placement: key.placement } : {}),
67
+ ...(key.compartmentKind ? { compartmentKind: key.compartmentKind } : {}),
68
+ ...(key.source ? { source: key.source.toLowerCase() } : {}),
69
+ };
70
+ }
71
+ function coveragePath(key) {
72
+ const normalized = normalizedCoverageKey(key);
73
+ const encoded = Buffer.from(JSON.stringify(normalized)).toString("base64url");
74
+ return path.join(coverageDir(normalized.agentId), `${encoded}.json`);
75
+ }
54
76
  function normalizeSearchText(privateEnvelope) {
77
+ const readableText = (0, core_1.privateMailEnvelopeReadableText)(privateEnvelope);
55
78
  return [
56
79
  privateEnvelope.subject,
57
80
  privateEnvelope.snippet,
58
- privateEnvelope.text.slice(0, SEARCH_TEXT_EXCERPT_LIMIT),
81
+ readableText.slice(0, SEARCH_TEXT_EXCERPT_LIMIT),
59
82
  privateEnvelope.from.join(" "),
60
83
  ].join("\n").toLowerCase();
61
84
  }
@@ -95,6 +118,7 @@ function loadCache(agentId) {
95
118
  return state.docs;
96
119
  }
97
120
  function buildMailSearchCacheDocument(message, privateEnvelope) {
121
+ const readableText = (0, core_1.privateMailEnvelopeReadableText)(privateEnvelope);
98
122
  return {
99
123
  schemaVersion: 1,
100
124
  messageId: message.id,
@@ -107,9 +131,10 @@ function buildMailSearchCacheDocument(message, privateEnvelope) {
107
131
  from: [...privateEnvelope.from],
108
132
  subject: privateEnvelope.subject,
109
133
  snippet: privateEnvelope.snippet,
110
- textExcerpt: privateEnvelope.text.slice(0, SEARCH_TEXT_EXCERPT_LIMIT),
134
+ textExcerpt: readableText.slice(0, SEARCH_TEXT_EXCERPT_LIMIT),
111
135
  untrustedContentWarning: privateEnvelope.untrustedContentWarning,
112
136
  searchText: normalizeSearchText(privateEnvelope),
137
+ textProjectionVersion: exports.MAIL_SEARCH_TEXT_PROJECTION_VERSION,
113
138
  attachmentCount: privateEnvelope.attachments.length,
114
139
  };
115
140
  }
@@ -118,8 +143,9 @@ function upsertMailSearchCacheDocument(message, privateEnvelope) {
118
143
  const dir = cacheDir(message.agentId);
119
144
  fs.mkdirSync(dir, { recursive: true });
120
145
  fs.writeFileSync(cachePath(message.agentId, message.id), `${JSON.stringify(document)}\n`, "utf-8");
121
- const docs = loadCache(message.agentId);
122
- docs.set(document.messageId, document);
146
+ const state = cacheState(message.agentId);
147
+ if (state.loaded)
148
+ state.docs.set(document.messageId, document);
123
149
  (0, runtime_1.emitNervesEvent)({
124
150
  component: "senses",
125
151
  event: "senses.mail_search_cache_upserted",
@@ -146,8 +172,9 @@ function syncMailSearchCacheMetadata(message) {
146
172
  ...(message.source ? { source: message.source } : {}),
147
173
  };
148
174
  fs.writeFileSync(cachePath(message.agentId, message.id), `${JSON.stringify(updated)}\n`, "utf-8");
149
- const docs = loadCache(message.agentId);
150
- docs.set(updated.messageId, updated);
175
+ const state = cacheState(message.agentId);
176
+ if (state.loaded)
177
+ state.docs.set(updated.messageId, updated);
151
178
  }
152
179
  function sourceMatches(source, filter) {
153
180
  if (!filter)
@@ -177,6 +204,49 @@ function searchMailSearchCache(filters) {
177
204
  }
178
205
  return typeof filters.limit === "number" ? ordered.slice(0, filters.limit) : ordered;
179
206
  }
207
+ function readMailSearchCoverageRecord(key) {
208
+ const document = readJsonDocument(coveragePath(key));
209
+ if (!document || document.schemaVersion !== 1 || document.agentId !== key.agentId)
210
+ return null;
211
+ const normalized = normalizedCoverageKey(key);
212
+ const stored = normalizedCoverageKey(document);
213
+ if (JSON.stringify(stored) !== JSON.stringify(normalized))
214
+ return null;
215
+ return document;
216
+ }
217
+ function writeMailSearchCoverageRecord(record) {
218
+ const normalized = normalizedCoverageKey(record);
219
+ const document = {
220
+ schemaVersion: 1,
221
+ ...normalized,
222
+ indexedAt: record.indexedAt,
223
+ visibleMessageCount: record.visibleMessageCount,
224
+ cachedMessageCount: record.cachedMessageCount,
225
+ decryptableMessageCount: record.decryptableMessageCount,
226
+ skippedMessageCount: record.skippedMessageCount,
227
+ ...(record.messageIndexFingerprint ? { messageIndexFingerprint: record.messageIndexFingerprint } : {}),
228
+ textProjectionVersion: record.textProjectionVersion,
229
+ ...(record.oldestReceivedAt ? { oldestReceivedAt: record.oldestReceivedAt } : {}),
230
+ ...(record.newestReceivedAt ? { newestReceivedAt: record.newestReceivedAt } : {}),
231
+ };
232
+ fs.mkdirSync(coverageDir(document.agentId), { recursive: true });
233
+ fs.writeFileSync(coveragePath(document), `${JSON.stringify(document)}\n`, "utf-8");
234
+ (0, runtime_1.emitNervesEvent)({
235
+ component: "senses",
236
+ event: "senses.mail_search_coverage_written",
237
+ message: "mail search coverage record written",
238
+ meta: {
239
+ agentId: document.agentId,
240
+ placement: document.placement ?? null,
241
+ compartmentKind: document.compartmentKind ?? null,
242
+ source: document.source ?? null,
243
+ storeKind: document.storeKind,
244
+ visibleMessageCount: document.visibleMessageCount,
245
+ decryptableMessageCount: document.decryptableMessageCount,
246
+ },
247
+ });
248
+ return document;
249
+ }
180
250
  function resetMailSearchCacheForTests() {
181
251
  cacheStates.clear();
182
252
  }
@@ -40,6 +40,7 @@ exports.bodyMapSection = bodyMapSection;
40
40
  exports.runtimeInfoSection = runtimeInfoSection;
41
41
  exports.toolRestrictionSection = toolRestrictionSection;
42
42
  exports.startOfTurnPacketSection = startOfTurnPacketSection;
43
+ exports.tripLedgerTruthSection = tripLedgerTruthSection;
43
44
  exports.pulseSection = pulseSection;
44
45
  exports.centerOfGravitySteeringSection = centerOfGravitySteeringSection;
45
46
  exports.commitmentsSection = commitmentsSection;
@@ -83,6 +84,7 @@ const daemon_health_1 = require("../heart/daemon/daemon-health");
83
84
  const scrutiny_1 = require("./scrutiny");
84
85
  const pulse_1 = require("../heart/daemon/pulse");
85
86
  const provider_visibility_1 = require("../heart/provider-visibility");
87
+ const store_1 = require("../trips/store");
86
88
  function flattenSystemPrompt(sp) {
87
89
  const parts = [sp.stable, sp.volatile].filter(Boolean);
88
90
  return parts.join("\n\n");
@@ -472,6 +474,7 @@ function senseRuntimeGuidance(channel, preReadStatusLines) {
472
474
  lines.push("mail setup truth: Agent Mail uses Mailroom, not HEY OAuth/IMAP. For the full work substrate account, the agent-runnable command is `ouro account ensure --agent <agent> --owner-email <email> --source hey`; use `ouro connect mail --agent <agent> --owner-email <email> --source hey` for mail-only repair/provisioning, or `--no-delegated-source` for native-only mail. The detailed runbook is `docs/agent-mail-setup.md`.");
473
475
  lines.push("mail setup truth: HEY archive bootstrap still depends on HEY's browser-only export, but I should not offload path-discovery ceremony to the human. If browser MCP/Playwright downloaded the archive, I first try `ouro mail import-mbox --discover --owner-email <email> --source hey --agent <agent>` so Ouro can find the sandboxed copy in a repo/worktree `.playwright-mcp`, the home `.playwright-mcp`, or Downloads. If discovery cannot find a unique file, then I ask the human for the local MBOX path and run `ouro mail import-mbox --file <path> --owner-email <email> --source hey --agent <agent>` myself.");
474
476
  lines.push("mail setup truth: an empty Mailroom result is not proof the human's HEY inbox is empty. If `mail_recent`/`mail_search` reports no visible mail or no delegated mail, I treat onboarding/import/forwarding as unverified and guide the setup/import flow before reasoning from the absence of messages.");
477
+ lines.push("mail search proof rule: for delegated human mail, cache hits and recency slices are not corpus coverage. Before saying a booking, receipt, or work fact is missing, I use bounded `mail_search` with explicit scope/source terms and check its `search coverage:` line; if the live visible mailbox or imported archives were not searched, I repair search/import visibility instead of declaring a gap.");
475
478
  lines.push("mail validation answer shape: when a human asks for Agent Mail golden paths, answer with only these four named paths before claiming setup works:");
476
479
  lines.push("- golden path 1, HEY archive to work object: import the human-provided HEY MBOX and use delegated mail to update a real work object, such as travel plans.");
477
480
  lines.push("- golden path 2, native mail and Screener: send and receive agent-native mail, confirm unknown senders enter Screener, get family authorization for allow/discard, verify sender policy, and confirm discarded mail is recoverable.");
@@ -712,6 +715,53 @@ function pendingMessagesSection(options) {
712
715
  }
713
716
  return lines.join("\n");
714
717
  }
718
+ function tripPromptAccessAllowed(channel, context) {
719
+ const senseType = (0, channel_1.getChannelCapabilities)(channel).senseType;
720
+ if (senseType === "local" || senseType === "internal")
721
+ return true;
722
+ return !!context?.friend && (0, types_1.isTrustedLevel)(context.friend.trustLevel);
723
+ }
724
+ function tripRecordDateRange(trip) {
725
+ if (trip.startDate && trip.endDate)
726
+ return `${trip.startDate} -> ${trip.endDate}`;
727
+ return trip.startDate ?? trip.endDate ?? "undated";
728
+ }
729
+ function renderTripLedgerSummary(trip) {
730
+ return `- ${trip.tripId} :: "${trip.name}" [${trip.status}; ${tripRecordDateRange(trip)}; legs: ${trip.legs.length}; updated: ${trip.updatedAt}]`;
731
+ }
732
+ function tripLedgerTruthSection(channel, context) {
733
+ if (!tripPromptAccessAllowed(channel, context))
734
+ return "";
735
+ let tripIds;
736
+ try {
737
+ tripIds = (0, store_1.listTripIds)((0, identity_1.getAgentName)());
738
+ }
739
+ catch {
740
+ return "";
741
+ }
742
+ if (tripIds.length === 0)
743
+ return "";
744
+ const lines = [
745
+ "## trip ledger truth",
746
+ "The trip ledger is the canonical structured source for travel plans. It outranks friend notes, old handoffs, and memory when those disagree.",
747
+ "When asked about travel plans, bookings, itinerary gaps, or what changed, I check `trip_status`, `trip_get`, or `trip_calendar` before answering from memory. I use `mail_search`/`mail_body` when the ledger is missing a needed fact or when verifying a claimed absence.",
748
+ "If a leg is `tentative`, I say it is tentative/inferred. I do not call it a booking or a gap unless the mail evidence supports that.",
749
+ "known trips:",
750
+ ];
751
+ const visibleTripIds = tripIds.slice(0, 8);
752
+ for (const tripId of visibleTripIds) {
753
+ try {
754
+ lines.push(renderTripLedgerSummary((0, store_1.readTripRecord)((0, identity_1.getAgentName)(), tripId)));
755
+ }
756
+ catch {
757
+ lines.push(`- ${tripId} :: unreadable right now; use trip_get before reasoning from it.`);
758
+ }
759
+ }
760
+ if (tripIds.length > visibleTripIds.length) {
761
+ lines.push(`- ${tripIds.length - visibleTripIds.length} more trip(s); use trip_status for the full list.`);
762
+ }
763
+ return lines.join("\n");
764
+ }
715
765
  /**
716
766
  * The pulse section: machine-wide situational awareness shared across all
717
767
  * peer agents on this machine. Reads ~/.ouro-cli/pulse.json (written by
@@ -1357,6 +1407,7 @@ async function buildSystem(channel = "cli", options, context) {
1357
1407
  "# dynamic state for this turn",
1358
1408
  startOfTurnPacketSection(options),
1359
1409
  pulseSection(channel),
1410
+ tripLedgerTruthSection(channel, context),
1360
1411
  liveWorldStateSection(options),
1361
1412
  pendingMessagesSection(options),
1362
1413
  activeWorkSection(options),
@@ -301,7 +301,7 @@ const CREDENTIAL_TRUSTED_TOOLS = new Set(["credential_get", "credential_list"]);
301
301
  // advisory and geocode are public APIs but gated for consistency)
302
302
  // Flight search is also friend+ (read-only, no payment)
303
303
  const TRAVEL_TRUSTED_TOOLS = new Set(["weather_lookup", "travel_advisory", "geocode_search", "flight_search"]);
304
- const MAIL_FAMILY_TOOLS = new Set(["mail_screener", "mail_decide", "mail_access_log", "mail_send"]);
304
+ const MAIL_FAMILY_TOOLS = new Set(["mail_screener", "mail_decide", "mail_access_log", "mail_send", "mail_index_refresh"]);
305
305
  const MAIL_DELEGATED_READ_TOOLS = new Set(["mail_recent", "mail_search"]);
306
306
  function mailTrustGuardrail(toolName, args, context) {
307
307
  if (MAIL_FAMILY_TOOLS.has(toolName)) {