@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.
@@ -8,6 +8,7 @@ exports.renderCachedMessageSummary = renderCachedMessageSummary;
8
8
  exports.mergeCachedMailSearchDocuments = mergeCachedMailSearchDocuments;
9
9
  exports.searchSuccessfulImportArchives = searchSuccessfulImportArchives;
10
10
  const node_fs_1 = __importDefault(require("node:fs"));
11
+ const node_crypto_1 = require("node:crypto");
11
12
  const types_1 = require("../mind/friends/types");
12
13
  const file_store_1 = require("../mailroom/file-store");
13
14
  const reader_1 = require("../mailroom/reader");
@@ -24,6 +25,8 @@ const credential_access_1 = require("./credential-access");
24
25
  const background_operations_1 = require("../heart/background-operations");
25
26
  const mail_import_discovery_1 = require("../heart/mail-import-discovery");
26
27
  const identity_1 = require("../heart/identity");
28
+ const MAIL_SEARCH_INDEX_FETCH_CONCURRENCY = 80;
29
+ const MAIL_SEARCH_INDEX_BATCH_SIZE = 500;
27
30
  function trustAllowsMailRead(ctx) {
28
31
  const trustLevel = ctx?.context?.friend?.trustLevel;
29
32
  const allowed = trustLevel === undefined || (0, types_1.isTrustedLevel)(trustLevel);
@@ -258,6 +261,233 @@ function cacheDecryptedMessages(messages) {
258
261
  (0, body_cache_1.cacheMailBody)(message);
259
262
  }
260
263
  }
264
+ function cacheAndFilterDecryptedSearchMessages(messages, terms) {
265
+ const matches = [];
266
+ for (const message of messages) {
267
+ const document = (0, search_cache_1.upsertMailSearchCacheDocument)(message, message.private);
268
+ (0, body_cache_1.cacheMailBody)(message);
269
+ if (terms.some((term) => document.searchText.includes(term)))
270
+ matches.push(document);
271
+ }
272
+ return matches;
273
+ }
274
+ function explicitDelegatedSearch(args, requestedScope) {
275
+ return requestedScope === "delegated" || requestedScope === "all" || !!args.source?.trim();
276
+ }
277
+ function appendDelegatedSearchCoverage(body, input) {
278
+ if (!input.include)
279
+ return body;
280
+ const liveCoverage = input.liveCoverageNote
281
+ ? `live visible messages searched=${input.liveMessagesSearched} (${input.liveCoverageNote}).`
282
+ : `live visible messages searched=${input.liveMessagesSearched}.`;
283
+ const indexedCoverage = input.coverageRecord
284
+ ? ` hosted search index coverage=${input.coverageRecord.decryptableMessageCount}/${input.coverageRecord.visibleMessageCount} decryptable messages as of ${input.coverageRecord.indexedAt}.`
285
+ : "";
286
+ return [
287
+ body,
288
+ [
289
+ "search coverage:",
290
+ `local cache matches=${input.cachedMatches};`,
291
+ `imported archive matches=${input.importedArchiveMatches}${input.importedArchiveSearched || !input.importedArchiveNote ? "" : ` (${input.importedArchiveNote})`};`,
292
+ liveCoverage,
293
+ indexedCoverage.trim(),
294
+ "cache hits are not proof of absence.",
295
+ ].filter(Boolean).join(" "),
296
+ ].join("\n\n");
297
+ }
298
+ async function mapWithConcurrency(items, concurrency, worker) {
299
+ /* v8 ignore next -- refresh callers pass only non-empty slices into the worker pool. @preserve */
300
+ if (items.length === 0)
301
+ return [];
302
+ const results = new Array(items.length);
303
+ let nextIndex = 0;
304
+ const workerLoop = async () => {
305
+ while (true) {
306
+ const current = nextIndex;
307
+ nextIndex += 1;
308
+ if (current >= items.length)
309
+ return;
310
+ results[current] = await worker(items[current], current);
311
+ }
312
+ };
313
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => workerLoop()));
314
+ return results;
315
+ }
316
+ function mailListFilters(input) {
317
+ return {
318
+ agentId: input.agentId,
319
+ ...(input.placement ? { placement: input.placement } : {}),
320
+ ...(input.scope ? { compartmentKind: input.scope } : {}),
321
+ ...(input.source ? { source: input.source } : {}),
322
+ };
323
+ }
324
+ function mailSearchCoverageKey(input) {
325
+ return {
326
+ agentId: input.agentId,
327
+ storeKind: input.storeKind,
328
+ ...(input.placement ? { placement: input.placement } : {}),
329
+ ...(input.scope ? { compartmentKind: input.scope } : {}),
330
+ ...(input.source ? { source: input.source } : {}),
331
+ };
332
+ }
333
+ function mailSearchCacheDocumentNeedsProjectionRefresh(document) {
334
+ if (document.textProjectionVersion === search_cache_1.MAIL_SEARCH_TEXT_PROJECTION_VERSION)
335
+ return false;
336
+ return document.textExcerpt.trim().length === 0;
337
+ }
338
+ function mailSearchCoverageSnapshot(records) {
339
+ const normalizedRecords = records
340
+ .map((record) => ({
341
+ id: record.id,
342
+ receivedAt: record.receivedAt,
343
+ placement: record.placement,
344
+ compartmentKind: record.compartmentKind,
345
+ source: record.source?.toLowerCase() ?? null,
346
+ }))
347
+ .sort((left, right) => left.id.localeCompare(right.id));
348
+ return {
349
+ visibleMessageCount: records.length,
350
+ messageIndexFingerprint: (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(normalizedRecords)).digest("hex"),
351
+ ...(oldestReceivedAt(records) ? { oldestReceivedAt: oldestReceivedAt(records) } : {}),
352
+ ...(newestReceivedAt(records) ? { newestReceivedAt: newestReceivedAt(records) } : {}),
353
+ };
354
+ }
355
+ function mailSearchCoverageStalenessReason(record, currentSnapshot) {
356
+ if (!record)
357
+ return "hosted search index incomplete";
358
+ if (record.textProjectionVersion !== search_cache_1.MAIL_SEARCH_TEXT_PROJECTION_VERSION)
359
+ return "hosted search index needs projection refresh";
360
+ if (!record.messageIndexFingerprint)
361
+ return "hosted search index needs corpus refresh";
362
+ if (!currentSnapshot)
363
+ return "hosted search index cannot validate current corpus";
364
+ if (record.visibleMessageCount !== currentSnapshot.visibleMessageCount
365
+ || record.messageIndexFingerprint !== currentSnapshot.messageIndexFingerprint
366
+ || (record.oldestReceivedAt ?? "") !== (currentSnapshot.oldestReceivedAt ?? "")
367
+ || (record.newestReceivedAt ?? "") !== (currentSnapshot.newestReceivedAt ?? "")) {
368
+ return "hosted search index is stale; current message index differs from coverage";
369
+ }
370
+ return "";
371
+ }
372
+ function mailSearchCoverageIsCurrent(record, currentSnapshot) {
373
+ return mailSearchCoverageStalenessReason(record, currentSnapshot) === "";
374
+ }
375
+ function mailSearchCoverageUnsearchableReason(record) {
376
+ if (!record)
377
+ return "";
378
+ const unsearchableCount = Math.max(record.skippedMessageCount, record.visibleMessageCount - record.decryptableMessageCount, 0);
379
+ if (unsearchableCount === 0)
380
+ return "";
381
+ const noun = unsearchableCount === 1 ? "message was" : "messages were";
382
+ return `${unsearchableCount} visible ${noun} not searchable because the index skipped missing/decryption-key mail`;
383
+ }
384
+ function newestReceivedAt(records) {
385
+ return records.reduce((newest, record) => {
386
+ if (!newest || Date.parse(record.receivedAt) > Date.parse(newest))
387
+ return record.receivedAt;
388
+ return newest;
389
+ }, undefined);
390
+ }
391
+ function oldestReceivedAt(records) {
392
+ return records.reduce((oldest, record) => {
393
+ if (!oldest || Date.parse(record.receivedAt) < Date.parse(oldest))
394
+ return record.receivedAt;
395
+ return oldest;
396
+ }, undefined);
397
+ }
398
+ async function refreshMailSearchIndex(input) {
399
+ const filters = mailListFilters(input);
400
+ const indexedRecords = await input.store.listMessageIndexRecords?.(filters);
401
+ const visibleRecords = indexedRecords ?? (await input.store.listMessages(filters)).map((message) => ({
402
+ schemaVersion: 1,
403
+ id: message.id,
404
+ agentId: message.agentId,
405
+ compartmentKind: message.compartmentKind,
406
+ placement: message.placement,
407
+ ...(message.source ? { source: message.source } : {}),
408
+ receivedAt: message.receivedAt,
409
+ }));
410
+ const visibleIds = new Set(visibleRecords.map((record) => record.id));
411
+ const coverageSnapshot = mailSearchCoverageSnapshot(visibleRecords);
412
+ const cachedForScope = (0, search_cache_1.searchMailSearchCache)({
413
+ agentId: input.agentId,
414
+ placement: input.placement,
415
+ compartmentKind: input.scope,
416
+ source: input.source,
417
+ });
418
+ const cachedVisibleIds = new Set(cachedForScope
419
+ .filter((document) => visibleIds.has(document.messageId))
420
+ .filter((document) => !mailSearchCacheDocumentNeedsProjectionRefresh(document))
421
+ .map((document) => document.messageId));
422
+ const idsToFetch = visibleRecords
423
+ .filter((record) => !cachedVisibleIds.has(record.id))
424
+ .map((record) => record.id);
425
+ const failures = [];
426
+ let fetchedCount = 0;
427
+ let skippedCount = 0;
428
+ for (let start = 0; start < idsToFetch.length; start += MAIL_SEARCH_INDEX_BATCH_SIZE) {
429
+ const batchIds = idsToFetch.slice(start, start + MAIL_SEARCH_INDEX_BATCH_SIZE);
430
+ const fetchedMessages = await mapWithConcurrency(batchIds, MAIL_SEARCH_INDEX_FETCH_CONCURRENCY, async (id) => {
431
+ try {
432
+ const message = input.store.getIndexedMessageById
433
+ ? await input.store.getIndexedMessageById(id)
434
+ : await input.store.getMessage(id);
435
+ return { id, message, error: message ? null : "indexed message was not retrievable" };
436
+ }
437
+ catch (error) {
438
+ return { id, message: null, error: error instanceof Error ? error.message : String(error) };
439
+ }
440
+ });
441
+ failures.push(...fetchedMessages.filter((entry) => entry.error !== null));
442
+ const fetchedStored = fetchedMessages
443
+ .map((entry) => entry.message)
444
+ .filter((message) => message !== null);
445
+ const result = decryptVisibleMessages(fetchedStored, input.privateKeys);
446
+ cacheDecryptedMessages(result.decrypted);
447
+ fetchedCount += fetchedStored.length;
448
+ skippedCount += result.skipped.length;
449
+ (0, runtime_1.emitNervesEvent)({
450
+ component: "repertoire",
451
+ event: "repertoire.mail_search_index_refresh_progress",
452
+ message: "mail search index refresh cached a batch",
453
+ meta: {
454
+ agentId: input.agentId,
455
+ fetched: fetchedCount,
456
+ totalToFetch: idsToFetch.length,
457
+ alreadyCached: cachedVisibleIds.size,
458
+ failures: failures.length,
459
+ },
460
+ });
461
+ }
462
+ const refreshedCachedForScope = (0, search_cache_1.searchMailSearchCache)({
463
+ agentId: input.agentId,
464
+ placement: input.placement,
465
+ compartmentKind: input.scope,
466
+ source: input.source,
467
+ }).filter((document) => visibleIds.has(document.messageId));
468
+ if (failures.length > 0) {
469
+ const sample = failures.slice(0, 3).map((entry) => `${entry.id}: ${entry.error}`).join("; ");
470
+ throw new Error(`mail search index refresh incomplete after fetching ${fetchedCount}/${idsToFetch.length} missing message(s); ${failures.length} fetch failed. first failure(s): ${sample}`);
471
+ }
472
+ const coverage = (0, search_cache_1.writeMailSearchCoverageRecord)({
473
+ schemaVersion: 1,
474
+ ...mailSearchCoverageKey(input),
475
+ indexedAt: new Date().toISOString(),
476
+ visibleMessageCount: visibleRecords.length,
477
+ cachedMessageCount: refreshedCachedForScope.length,
478
+ decryptableMessageCount: refreshedCachedForScope.length,
479
+ skippedMessageCount: skippedCount,
480
+ messageIndexFingerprint: coverageSnapshot.messageIndexFingerprint,
481
+ textProjectionVersion: search_cache_1.MAIL_SEARCH_TEXT_PROJECTION_VERSION,
482
+ ...(coverageSnapshot.oldestReceivedAt ? { oldestReceivedAt: coverageSnapshot.oldestReceivedAt } : {}),
483
+ ...(coverageSnapshot.newestReceivedAt ? { newestReceivedAt: coverageSnapshot.newestReceivedAt } : {}),
484
+ });
485
+ return {
486
+ coverage,
487
+ fetched: fetchedCount,
488
+ alreadyCached: cachedVisibleIds.size,
489
+ };
490
+ }
261
491
  function accessProvenance(message) {
262
492
  const provenance = (0, core_1.describeMailProvenance)(message);
263
493
  return {
@@ -1051,83 +1281,230 @@ exports.mailToolDefinitions = [
1051
1281
  return resolved.error;
1052
1282
  const scope = requestedScope === "all"
1053
1283
  ? undefined
1054
- : requestedScope ?? (familyOrAgentSelf(ctx) ? undefined : "native");
1284
+ : requestedScope ?? (args.source ? "delegated" : (familyOrAgentSelf(ctx) ? undefined : "native"));
1055
1285
  const limit = numberArg(args.limit, 10, 1, 20);
1056
- const cachedMatches = (0, search_cache_1.searchMailSearchCache)({
1057
- agentId: resolved.agentName,
1058
- placement: parsePlacement(args.placement),
1059
- compartmentKind: scope,
1060
- source: args.source,
1061
- queryTerms: terms,
1062
- limit,
1063
- });
1064
- if (cachedMatches.length > 0 && cachedMatches.every((message) => message.compartmentKind === "delegated")) {
1065
- await resolved.store.recordAccess({
1286
+ const includeCoverage = explicitDelegatedSearch(args, requestedScope);
1287
+ const placement = parsePlacement(args.placement);
1288
+ const hostedDelegatedSearch = resolved.storeKind === "azure-blob" && scope !== "native";
1289
+ const cachedMatches = hostedDelegatedSearch
1290
+ ? (0, search_cache_1.searchMailSearchCache)({
1291
+ agentId: resolved.agentName,
1292
+ placement,
1293
+ compartmentKind: scope,
1294
+ source: args.source,
1295
+ queryTerms: terms,
1296
+ limit,
1297
+ })
1298
+ : [];
1299
+ const storedCoverageRecord = hostedDelegatedSearch
1300
+ ? (0, search_cache_1.readMailSearchCoverageRecord)(mailSearchCoverageKey({
1066
1301
  agentId: resolved.agentName,
1067
- tool: "mail_search",
1068
- reason: args.reason || `search: ${query}`,
1302
+ placement,
1303
+ scope,
1304
+ source: args.source,
1305
+ storeKind: resolved.storeKind,
1306
+ }))
1307
+ : null;
1308
+ let currentCoverageSnapshot = null;
1309
+ let coverageValidationError;
1310
+ if (hostedDelegatedSearch && storedCoverageRecord) {
1311
+ if (resolved.store.listMessageIndexRecords) {
1312
+ try {
1313
+ const currentIndexRecords = await resolved.store.listMessageIndexRecords(mailListFilters({
1314
+ agentId: resolved.agentName,
1315
+ placement,
1316
+ scope,
1317
+ source: args.source,
1318
+ }));
1319
+ currentCoverageSnapshot = currentIndexRecords ? mailSearchCoverageSnapshot(currentIndexRecords) : null;
1320
+ }
1321
+ catch (error) {
1322
+ coverageValidationError = error instanceof Error ? error.message : String(error);
1323
+ }
1324
+ }
1325
+ else {
1326
+ coverageValidationError = "hosted store does not expose message index records";
1327
+ }
1328
+ }
1329
+ let coverageStalenessReason = mailSearchCoverageStalenessReason(storedCoverageRecord, currentCoverageSnapshot);
1330
+ if (coverageValidationError && coverageStalenessReason === "hosted search index cannot validate current corpus") {
1331
+ coverageStalenessReason = `${coverageStalenessReason} (${coverageValidationError})`;
1332
+ }
1333
+ const coverageRecord = mailSearchCoverageIsCurrent(storedCoverageRecord, currentCoverageSnapshot) ? storedCoverageRecord : null;
1334
+ const coverageUnsearchableReason = mailSearchCoverageUnsearchableReason(coverageRecord);
1335
+ let result = { decrypted: [], skipped: [] };
1336
+ let liveMessagesSearched = 0;
1337
+ let liveCoverageNote;
1338
+ let decryptableCachedMatches = cachedMatches;
1339
+ let liveMatches = [];
1340
+ if (hostedDelegatedSearch) {
1341
+ liveCoverageNote = coverageRecord
1342
+ ? coverageUnsearchableReason
1343
+ ? `hosted search index is current, but ${coverageUnsearchableReason}; do not treat misses as proof of absence`
1344
+ : "hosted search index complete; no inline full-mailbox scan needed"
1345
+ : storedCoverageRecord
1346
+ ? `${coverageStalenessReason}; run mail_index_refresh for this scope/source before treating missing hits as absent`
1347
+ : "hosted search index incomplete; run mail_index_refresh for this scope/source before treating missing hits as absent";
1348
+ }
1349
+ else {
1350
+ const all = await resolved.store.listMessages({
1351
+ agentId: resolved.agentName,
1352
+ placement,
1353
+ compartmentKind: scope,
1354
+ source: args.source,
1069
1355
  });
1070
- return cachedMatches.map((message) => renderCachedMessageSummary(message, terms)).join("\n\n");
1356
+ liveMessagesSearched = all.length;
1357
+ result = decryptVisibleMessages(all, resolved.config.privateKeys);
1358
+ liveMatches = cacheAndFilterDecryptedSearchMessages(result.decrypted, terms);
1071
1359
  }
1360
+ let matching = mergeCachedMailSearchDocuments(decryptableCachedMatches, liveMatches, limit, terms);
1361
+ let importedMatches = [];
1362
+ let importedArchiveSearched = false;
1363
+ let importedArchiveNote;
1072
1364
  if (scope !== "native"
1073
1365
  && resolved.storeKind === "azure-blob"
1074
- && (cachedMatches.length === 0 || cachedMatches.some((message) => message.compartmentKind !== "delegated"))) {
1075
- const importedMatches = await searchSuccessfulImportArchives({
1366
+ && !coverageRecord
1367
+ && matching.length < limit) {
1368
+ importedArchiveSearched = true;
1369
+ importedMatches = await searchSuccessfulImportArchives({
1076
1370
  agentId: resolved.agentName,
1077
1371
  config: resolved.config,
1078
1372
  queryTerms: terms,
1079
1373
  limit,
1080
1374
  ...(args.source ? { source: args.source } : {}),
1081
1375
  });
1082
- if (importedMatches.length > 0) {
1083
- const mergedMatches = mergeCachedMailSearchDocuments(cachedMatches, importedMatches, limit, terms);
1084
- await resolved.store.recordAccess({
1085
- agentId: resolved.agentName,
1086
- tool: "mail_search",
1087
- reason: args.reason || `search: ${query}`,
1088
- });
1089
- return mergedMatches.map((message) => renderCachedMessageSummary(message, terms)).join("\n\n");
1090
- }
1376
+ matching = mergeCachedMailSearchDocuments(decryptableCachedMatches, [...liveMatches, ...importedMatches], limit, terms);
1377
+ }
1378
+ else if (scope === "native" || resolved.storeKind !== "azure-blob") {
1379
+ importedArchiveNote = "not applicable for this store";
1380
+ }
1381
+ else if (coverageRecord) {
1382
+ importedArchiveNote = "skipped; hosted search index is complete";
1383
+ }
1384
+ else {
1385
+ importedArchiveNote = "skipped; live visible search filled the result limit";
1091
1386
  }
1092
- const all = await resolved.store.listMessages({
1093
- agentId: resolved.agentName,
1094
- placement: parsePlacement(args.placement),
1095
- compartmentKind: scope,
1096
- source: args.source,
1097
- });
1098
- const result = decryptVisibleMessages(all, resolved.config.privateKeys);
1099
- cacheDecryptedMessages(result.decrypted);
1100
- const matching = result.decrypted
1101
- .filter((message) => {
1102
- const haystack = [
1103
- message.private.subject,
1104
- message.private.snippet,
1105
- message.private.text,
1106
- message.private.from.join(" "),
1107
- ].join("\n").toLowerCase();
1108
- return terms.some((term) => haystack.includes(term));
1109
- })
1110
- .slice(0, limit);
1111
1387
  await resolved.store.recordAccess({
1112
1388
  agentId: resolved.agentName,
1113
1389
  tool: "mail_search",
1114
1390
  reason: args.reason || `search: ${query}`,
1115
1391
  });
1116
- if (all.length === 0) {
1117
- return renderEmptyMailResult({
1392
+ if (!hostedDelegatedSearch && liveMessagesSearched === 0 && matching.length === 0) {
1393
+ return appendDelegatedSearchCoverage(await renderEmptyMailResult({
1118
1394
  agentId: resolved.agentName,
1119
1395
  config: resolved.config,
1120
1396
  store: resolved.store,
1121
1397
  ...(scope ? { scope } : {}),
1122
1398
  ...(args.source ? { source: args.source } : {}),
1399
+ }), {
1400
+ include: includeCoverage,
1401
+ cachedMatches: cachedMatches.length,
1402
+ importedArchiveMatches: importedMatches.length,
1403
+ importedArchiveSearched,
1404
+ importedArchiveNote,
1405
+ liveMessagesSearched,
1406
+ coverageRecord,
1407
+ });
1408
+ }
1409
+ if (matching.length === 0) {
1410
+ const emptyBody = hostedDelegatedSearch && !coverageRecord
1411
+ ? "No indexed matching mail yet. Search index coverage is incomplete; run mail_index_refresh for this scope/source before treating this as absence."
1412
+ : hostedDelegatedSearch && coverageUnsearchableReason
1413
+ ? `No matching decryptable/indexed mail. Search index coverage is current, but ${coverageUnsearchableReason}; do not treat this as proof of absence until mail_index_refresh reports full decryptable coverage after keys are repaired.`
1414
+ : "No matching mail.";
1415
+ return appendDelegatedSearchCoverage(appendDecryptSkips(emptyBody, result.skipped), {
1416
+ include: includeCoverage,
1417
+ cachedMatches: cachedMatches.length,
1418
+ importedArchiveMatches: importedMatches.length,
1419
+ importedArchiveSearched,
1420
+ ...(importedArchiveNote ? { importedArchiveNote } : {}),
1421
+ liveMessagesSearched,
1422
+ ...(liveCoverageNote ? { liveCoverageNote } : {}),
1423
+ coverageRecord,
1123
1424
  });
1124
1425
  }
1125
- if (matching.length === 0)
1126
- return appendDecryptSkips("No matching mail.", result.skipped);
1127
- return appendDecryptSkips(matching.map(renderMessageSummary).join("\n\n"), result.skipped);
1426
+ return appendDelegatedSearchCoverage(appendDecryptSkips(matching.map((message) => renderCachedMessageSummary(message, terms)).join("\n\n"), result.skipped), {
1427
+ include: includeCoverage,
1428
+ cachedMatches: cachedMatches.length,
1429
+ importedArchiveMatches: importedMatches.length,
1430
+ importedArchiveSearched,
1431
+ ...(importedArchiveNote ? { importedArchiveNote } : {}),
1432
+ liveMessagesSearched,
1433
+ ...(liveCoverageNote ? { liveCoverageNote } : {}),
1434
+ coverageRecord,
1435
+ });
1128
1436
  },
1129
1437
  summaryKeys: ["query", "limit"],
1130
1438
  },
1439
+ {
1440
+ tool: {
1441
+ type: "function",
1442
+ function: {
1443
+ name: "mail_index_refresh",
1444
+ description: "Refresh the local decrypted search index for visible mail in a bounded scope/source. Use this before treating hosted delegated mail search misses as evidence of absence.",
1445
+ parameters: {
1446
+ type: "object",
1447
+ properties: {
1448
+ placement: { type: "string", enum: ["imbox", "screener", "discarded", "quarantine", "draft", "sent"], description: "Optional mailbox placement filter." },
1449
+ scope: { type: "string", enum: ["native", "delegated", "all"], description: "Optional mailbox scope. Defaults to delegated when source is set, otherwise all family/self-visible mail." },
1450
+ source: { type: "string", description: "Optional delegated source filter, e.g. hey." },
1451
+ reason: { type: "string", description: "Why you are refreshing the search index. Logged for audit." },
1452
+ },
1453
+ },
1454
+ },
1455
+ },
1456
+ handler: async (args, ctx) => {
1457
+ if (!trustAllowsMailRead(ctx))
1458
+ return "mail is private; this tool is only available in trusted contexts.";
1459
+ const requestedScope = args.scope === "all" ? "all" : parseScope(args.scope);
1460
+ if (requestedScope === "delegated" || requestedScope === "all" || args.source?.trim()) {
1461
+ const blocked = delegatedHumanMailBlocked(ctx);
1462
+ if (blocked)
1463
+ return blocked;
1464
+ }
1465
+ const resolved = await (0, reader_1.resolveMailroomReaderWithRefresh)();
1466
+ if (!resolved.ok)
1467
+ return resolved.error;
1468
+ const placement = parsePlacement(args.placement);
1469
+ const scope = requestedScope === "all"
1470
+ ? undefined
1471
+ : requestedScope ?? (args.source ? "delegated" : (familyOrAgentSelf(ctx) ? undefined : "native"));
1472
+ try {
1473
+ const refreshed = await refreshMailSearchIndex({
1474
+ agentId: resolved.agentName,
1475
+ store: resolved.store,
1476
+ privateKeys: resolved.config.privateKeys,
1477
+ storeKind: resolved.storeKind,
1478
+ placement,
1479
+ scope,
1480
+ source: args.source,
1481
+ });
1482
+ await resolved.store.recordAccess({
1483
+ agentId: resolved.agentName,
1484
+ tool: "mail_index_refresh",
1485
+ reason: args.reason || "refresh mail search index",
1486
+ });
1487
+ const { coverage } = refreshed;
1488
+ return [
1489
+ "mail search index refreshed.",
1490
+ `scope: ${scope ?? "all"}${args.source ? `; source: ${args.source}` : ""}${placement ? `; placement: ${placement}` : ""}`,
1491
+ `visible messages: ${coverage.visibleMessageCount}`,
1492
+ `decryptable cached messages: ${coverage.decryptableMessageCount}`,
1493
+ `missing-key skipped messages: ${coverage.skippedMessageCount}`,
1494
+ `fetched this run: ${refreshed.fetched}`,
1495
+ `already cached: ${refreshed.alreadyCached}`,
1496
+ `indexed at: ${coverage.indexedAt}`,
1497
+ ...(coverage.oldestReceivedAt ? [`oldest: ${coverage.oldestReceivedAt}`] : []),
1498
+ ...(coverage.newestReceivedAt ? [`newest: ${coverage.newestReceivedAt}`] : []),
1499
+ ].join("\n");
1500
+ }
1501
+ catch (error) {
1502
+ const message = error instanceof Error ? error.message : String(error);
1503
+ return `mail search index refresh failed: ${message}`;
1504
+ }
1505
+ },
1506
+ summaryKeys: ["scope", "source", "placement"],
1507
+ },
1131
1508
  {
1132
1509
  tool: {
1133
1510
  type: "function",
@@ -1177,9 +1554,10 @@ exports.mailToolDefinitions = [
1177
1554
  meta: { messageId },
1178
1555
  });
1179
1556
  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;
1557
+ const readableTextCached = (0, core_1.privateMailEnvelopeReadableText)(cached.private);
1558
+ const bodyCached = readableTextCached.length > maxCharsCached
1559
+ ? `${readableTextCached.slice(0, maxCharsCached - 3)}...`
1560
+ : readableTextCached;
1183
1561
  return [
1184
1562
  renderMessageSummary(cached),
1185
1563
  "",
@@ -1192,6 +1570,7 @@ exports.mailToolDefinitions = [
1192
1570
  return `No visible mail message found for ${messageId}.`;
1193
1571
  if (message.compartmentKind === "delegated") {
1194
1572
  const blocked = delegatedHumanMailBlocked(ctx);
1573
+ /* v8 ignore next -- same delegated trust gate as cached body and search paths; focused tests cover the blocked behavior. @preserve */
1195
1574
  if (blocked)
1196
1575
  return blocked;
1197
1576
  }
@@ -1216,9 +1595,10 @@ exports.mailToolDefinitions = [
1216
1595
  (0, body_cache_1.cacheMailBody)(decrypted);
1217
1596
  const maxChars = numberArg(args.max_chars, 2000, 200, 6000);
1218
1597
  /* 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 */
1219
- const body = decrypted.private.text.length > maxChars
1220
- ? `${decrypted.private.text.slice(0, maxChars - 3)}...`
1221
- : decrypted.private.text;
1598
+ const readableText = (0, core_1.privateMailEnvelopeReadableText)(decrypted.private);
1599
+ const body = readableText.length > maxChars
1600
+ ? `${readableText.slice(0, maxChars - 3)}...`
1601
+ : readableText;
1222
1602
  return [
1223
1603
  renderMessageSummary(decrypted),
1224
1604
  "",