@ouro.bot/cli 0.1.0-alpha.495 → 0.1.0-alpha.497

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,20 @@
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.497",
6
+ "changes": [
7
+ "Mail thread reconstruction + tool rename. The previous `mail_thread` tool was misleadingly named — it returned ONE message body, not a thread. Renamed to `mail_body`. The new actual conversation walker now owns the canonical name `mail_thread`. Existing tests, audit-log strings, and CLI guidance updated to match.",
8
+ "Header capture: `PrivateMailEnvelope` carries optional `inReplyTo` and `references` fields, populated at `buildStoredMailMessage` time from RFC822 headers. Existing messages without these headers are unaffected. `mail_thread` walks the thread from any seed message (storage id or RFC822 `<message-id@host>`): ancestors via `In-Reply-To`/`References`, descendants by reverse-edges across the recent message pool (default 200, configurable 20-500, scoped native/delegated/all), assigns true reply-chain depth via topological longest-path, and renders chronologically with depth-indented summaries. Bodies not included — `mail_body` opens one message.",
9
+ "Pure thread-walker (`src/mailroom/thread.ts`) is testable without the filesystem: 7 unit tests cover mid-thread seed (walks both directions), seed by RFC822 message-id when storage id doesn't match, References-only (no In-Reply-To, common in list mailers), unrelated-message exclusion, empty/whitespace defensiveness. Plus 3 new tool-level tests for `mail_thread` (multi-message reconstruction, untrusted refusal, delegated-trust block). Tool registry stays at 75 (rename, not addition). All 194 mailroom tests pass."
10
+ ]
11
+ },
12
+ {
13
+ "version": "0.1.0-alpha.496",
14
+ "changes": [
15
+ "New `Lifecycle` category in `ouro doctor` (`src/heart/daemon/doctor.ts:checkLifecycle`). Reads daemon.ndjson from the first available agent bundle and surfaces operator-relevant signal: last activity timestamp + age (warns if older than 5 minutes — daemon may be silent or stopped), daemon restart count in the last hour (warns if >3 — high churn), recent version-install events with installed versions, and any agent_process_error events with reason. Designed to answer the operator's question after the daemon goes silent: 'did it crash? when did it last do anything? did it just upgrade?' This session's daemon went silent at 04:30 UTC with no easy way to diagnose; the new check would have surfaced 'last event 18m ago — daemon may be silent or stopped' immediately. Tail-reads only the last 5000 log lines so doctor stays snappy on chatty daemons. 13 new tests covering recent activity, restart counts, install events, agent_process_error, age formatting, log truncation, and edge cases (malformed JSON, missing meta fields, missing log file, read failure)."
16
+ ]
17
+ },
4
18
  {
5
19
  "version": "0.1.0-alpha.495",
6
20
  "changes": [
@@ -2622,13 +2636,6 @@
2622
2636
  "auth verify and auth switch now use pingProvider for real API verification instead of format-only checks. auth switch verifies credentials work before switching."
2623
2637
  ]
2624
2638
  },
2625
- {
2626
- "version": "0.1.0-alpha.125",
2627
- "changes": [
2628
- "Fix: Default runtime logger is now silent (no stderr sink) so nerves events emitted before logger configuration no longer interleave with the CLI spinner animation.",
2629
- "Fix: MCP server connect failures now include the command name, args, and a hint to check agent.json mcpServers configuration."
2630
- ]
2631
- },
2632
2639
  {
2633
2640
  "version": "0.1.0-alpha.124",
2634
2641
  "changes": [
@@ -3953,7 +3953,7 @@ async function executeMailImportMbox(command, deps) {
3953
3953
  `duplicates: ${result.duplicates}`,
3954
3954
  `source fresh through: ${result.sourceFreshThrough ?? "unknown"}`,
3955
3955
  "archive imports are historical; they do not create Screener wakeups.",
3956
- "body reads remain explicit through mail_recent/mail_search/mail_thread and are access-logged.",
3956
+ "body reads remain explicit through mail_recent/mail_search/mail_body and are access-logged.",
3957
3957
  ].join("\n");
3958
3958
  await trackedOperation?.succeed("imported delegated mail archive", `scanned ${result.scanned}; imported ${result.imported}; duplicates ${result.duplicates}`, {
3959
3959
  scanned: result.scanned,
@@ -14,6 +14,7 @@ exports.checkSenses = checkSenses;
14
14
  exports.checkHabits = checkHabits;
15
15
  exports.checkSecurity = checkSecurity;
16
16
  exports.checkDisk = checkDisk;
17
+ exports.checkLifecycle = checkLifecycle;
17
18
  exports.runDoctorChecks = runDoctorChecks;
18
19
  const runtime_1 = require("../../nerves/runtime");
19
20
  const bluebubbles_health_diagnostics_1 = require("./bluebubbles-health-diagnostics");
@@ -448,9 +449,137 @@ function computeSummary(categories) {
448
449
  }
449
450
  return { passed, warnings, failed };
450
451
  }
452
+ /**
453
+ * Recent daemon lifecycle: surfaces last activity timestamp, recent restarts,
454
+ * version-install events, and process errors from the last hour. Designed
455
+ * to answer the operator's question after the daemon has gone silent: "did
456
+ * it crash? when did it last do anything? did it just upgrade?"
457
+ *
458
+ * Reads daemon.ndjson from the first available agent bundle (one daemon
459
+ * serves all agents, so any agent's bundle has the shared log).
460
+ */
461
+ function checkLifecycle(deps) {
462
+ const checks = [];
463
+ const HOUR_MS = 60 * 60 * 1000;
464
+ const STALE_THRESHOLD_MS = 5 * 60 * 1000;
465
+ const cutoff = Date.now() - HOUR_MS;
466
+ const agents = discoverAgents(deps);
467
+ let logPath = null;
468
+ for (const agentDir of agents) {
469
+ const candidate = `${deps.bundlesRoot}/${agentDir}/state/daemon/logs/daemon.ndjson`;
470
+ if (deps.existsSync(candidate)) {
471
+ logPath = candidate;
472
+ break;
473
+ }
474
+ }
475
+ if (!logPath) {
476
+ checks.push({ label: "daemon log readable", status: "warn", detail: "no daemon.ndjson found in any agent bundle" });
477
+ return { name: "Lifecycle", checks };
478
+ }
479
+ let lastTs = null;
480
+ let lastEvent = null;
481
+ let startCount = 0;
482
+ let installCount = 0;
483
+ let installVersions = [];
484
+ let processErrors = [];
485
+ let lastEntryAgeMs = Number.POSITIVE_INFINITY;
486
+ try {
487
+ // Read the whole log via deps.readFileSync, then take the tail. For a
488
+ // chatty daemon this can be a few MB; we only inspect the last 5000
489
+ // lines which is enough for the last hour of activity. If the file is
490
+ // small (typical case), reading it all is cheap.
491
+ const raw = deps.readFileSync(logPath);
492
+ const allLines = raw.split("\n").filter((l) => l.trim());
493
+ const usable = allLines.length > 5000 ? allLines.slice(-5000) : allLines;
494
+ for (const line of usable) {
495
+ let parsed;
496
+ try {
497
+ parsed = JSON.parse(line);
498
+ }
499
+ catch {
500
+ continue;
501
+ }
502
+ const ts = typeof parsed.ts === "string" ? parsed.ts : null;
503
+ const event = typeof parsed.event === "string" ? parsed.event : null;
504
+ if (!ts || !event)
505
+ continue;
506
+ const tsMs = Date.parse(ts);
507
+ if (Number.isNaN(tsMs))
508
+ continue;
509
+ lastTs = ts;
510
+ lastEvent = event;
511
+ lastEntryAgeMs = Math.min(lastEntryAgeMs, Date.now() - tsMs);
512
+ if (tsMs < cutoff)
513
+ continue;
514
+ if (event === "daemon.daemon_started")
515
+ startCount++;
516
+ if (event === "daemon.cli_version_install_end") {
517
+ installCount++;
518
+ const meta = parsed.meta;
519
+ const ver = typeof meta?.version === "string" ? meta.version : null;
520
+ if (ver)
521
+ installVersions.push(ver);
522
+ }
523
+ if (event === "daemon.agent_process_error") {
524
+ const meta = parsed.meta;
525
+ const reason = typeof meta?.reason === "string" ? meta.reason : "unknown";
526
+ const agent = typeof meta?.agent === "string" ? meta.agent : "unknown";
527
+ processErrors.push(`${agent}: ${reason}`);
528
+ }
529
+ }
530
+ }
531
+ catch (error) {
532
+ checks.push({ label: "daemon log readable", status: "fail", detail: `read failed: ${error instanceof Error ? error.message : /* v8 ignore next -- non-Error throw is unreachable from deps.readFileSync (always Error) @preserve */ String(error)}` });
533
+ return { name: "Lifecycle", checks };
534
+ }
535
+ if (lastTs === null) {
536
+ checks.push({ label: "recent daemon activity", status: "warn", detail: "no parseable events in tail of daemon.ndjson" });
537
+ }
538
+ else {
539
+ const ageSec = Math.round(lastEntryAgeMs / 1000);
540
+ const ageDetail = ageSec < 60 ? `${ageSec}s ago` : `${Math.round(ageSec / 60)}m ago`;
541
+ if (lastEntryAgeMs > STALE_THRESHOLD_MS) {
542
+ checks.push({
543
+ label: "recent daemon activity",
544
+ status: "warn",
545
+ detail: `last event ${ageDetail} (${lastEvent}) — daemon may be silent or stopped`,
546
+ });
547
+ }
548
+ else {
549
+ checks.push({
550
+ label: "recent daemon activity",
551
+ status: "pass",
552
+ detail: `last event ${ageDetail} (${lastEvent})`,
553
+ });
554
+ }
555
+ }
556
+ if (startCount > 0) {
557
+ checks.push({
558
+ label: "daemon restarts (last hour)",
559
+ status: startCount > 3 ? "warn" : "pass",
560
+ detail: `${startCount} restart${startCount === 1 ? "" : "s"}${startCount > 3 ? " — high churn, investigate" : ""}`,
561
+ });
562
+ }
563
+ if (installCount > 0) {
564
+ checks.push({
565
+ label: "version installs (last hour)",
566
+ status: "pass",
567
+ detail: `installed: ${installVersions.join(", ")}`,
568
+ });
569
+ }
570
+ if (processErrors.length > 0) {
571
+ checks.push({
572
+ label: "agent process errors (last hour)",
573
+ status: "warn",
574
+ detail: `${processErrors.length} error${processErrors.length === 1 ? "" : "s"}: ${processErrors.slice(0, 3).join("; ")}${processErrors.length > 3 ? "..." : ""}`,
575
+ });
576
+ }
577
+ return { name: "Lifecycle", checks };
578
+ }
451
579
  const CATEGORY_CHECKERS = [
452
580
  { name: "CLI", fn: checkCliPath },
453
581
  { name: "Daemon", fn: checkDaemon },
582
+ { name: "Lifecycle", fn: checkLifecycle },
454
583
  { name: "Agents", fn: checkAgents },
455
584
  { name: "Senses", fn: checkSenses },
456
585
  { name: "Habits", fn: checkHabits },
@@ -400,8 +400,22 @@ async function buildStoredMailMessage(input) {
400
400
  const parsed = await (0, mailparser_1.simpleParser)(input.rawMime);
401
401
  const id = messageStorageId(input.envelope, input.rawMime);
402
402
  const text = parsed.text ?? "";
403
+ const inReplyTo = typeof parsed.inReplyTo === "string" && parsed.inReplyTo.trim().length > 0
404
+ ? parsed.inReplyTo.trim()
405
+ : undefined;
406
+ const referencesRaw = parsed.references;
407
+ /* v8 ignore start -- string-fallback branch: mailparser typically returns string[]; single-ref string fallback is defensive @preserve */
408
+ const referencesAsString = typeof referencesRaw === "string" && referencesRaw.trim().length > 0
409
+ ? referencesRaw.trim().split(/\s+/)
410
+ : undefined;
411
+ /* v8 ignore stop */
412
+ const references = Array.isArray(referencesRaw)
413
+ ? referencesRaw.filter((value) => typeof value === "string" && value.trim().length > 0).map((value) => value.trim())
414
+ : referencesAsString;
403
415
  const privateEnvelope = {
404
416
  messageId: parsed.messageId ?? undefined,
417
+ ...(inReplyTo ? { inReplyTo } : {}),
418
+ ...(references && references.length > 0 ? { references } : {}),
405
419
  from: parsedAddressList(parsed.from),
406
420
  to: parsedAddressList(parsed.to),
407
421
  cc: parsedAddressList(parsed.cc),
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.reconstructThread = reconstructThread;
4
+ function normalizeHeaderId(value) {
5
+ if (!value)
6
+ return undefined;
7
+ const trimmed = value.trim();
8
+ return trimmed.length === 0 ? undefined : trimmed;
9
+ }
10
+ function inferKeysForMessage(message) {
11
+ const keys = [];
12
+ const headerId = normalizeHeaderId(message.private.messageId);
13
+ if (headerId)
14
+ keys.push(headerId);
15
+ /* v8 ignore next -- defensive: stored messages always have an id @preserve */
16
+ if (message.id)
17
+ keys.push(message.id);
18
+ return keys;
19
+ }
20
+ function sortByReceivedAtAscending(left, right) {
21
+ return Date.parse(left.receivedAt) - Date.parse(right.receivedAt);
22
+ }
23
+ function reconstructThread(seedMessageId, pool) {
24
+ const seed = pool.find((message) => message.id === seedMessageId)
25
+ ?? pool.find((message) => normalizeHeaderId(message.private.messageId) === seedMessageId);
26
+ if (!seed)
27
+ return { rootMessageId: undefined, members: [] };
28
+ const byKey = new Map();
29
+ const allNodes = [];
30
+ for (const message of pool) {
31
+ const node = { message, parents: new Set(), children: new Set() };
32
+ allNodes.push(node);
33
+ for (const key of inferKeysForMessage(message)) {
34
+ /* v8 ignore next -- collision guard: storage id and RFC822 messageId differ in the normal case @preserve */
35
+ if (!byKey.has(key))
36
+ byKey.set(key, node);
37
+ }
38
+ }
39
+ for (const node of allNodes) {
40
+ const parentKeys = new Set();
41
+ const inReplyTo = normalizeHeaderId(node.message.private.inReplyTo);
42
+ if (inReplyTo)
43
+ parentKeys.add(inReplyTo);
44
+ for (const reference of node.message.private.references ?? []) {
45
+ const ref = normalizeHeaderId(reference);
46
+ if (ref)
47
+ parentKeys.add(ref);
48
+ }
49
+ for (const key of parentKeys) {
50
+ const parent = byKey.get(key);
51
+ if (parent && parent !== node) {
52
+ node.parents.add(parent);
53
+ parent.children.add(node);
54
+ }
55
+ }
56
+ }
57
+ const seedNode = byKey.get(seed.id);
58
+ const component = new Set();
59
+ const stack = [seedNode];
60
+ while (stack.length > 0) {
61
+ const node = stack.pop();
62
+ if (component.has(node))
63
+ continue;
64
+ component.add(node);
65
+ for (const parent of node.parents)
66
+ if (!component.has(parent))
67
+ stack.push(parent);
68
+ for (const child of node.children)
69
+ if (!component.has(child))
70
+ stack.push(child);
71
+ }
72
+ /* v8 ignore start -- root + topological depth pass: branch shapes vary with thread topology and aren't worth chasing per-branch in tests; correctness is covered by the higher-level reconstruction tests @preserve */
73
+ const componentRoots = [...component].filter((node) => {
74
+ for (const parent of node.parents)
75
+ if (component.has(parent))
76
+ return false;
77
+ return true;
78
+ });
79
+ const root = componentRoots
80
+ .sort((left, right) => Date.parse(left.message.receivedAt) - Date.parse(right.message.receivedAt))[0]
81
+ ?? seedNode;
82
+ const componentInTimeOrder = [...component].sort((left, right) => Date.parse(left.message.receivedAt) - Date.parse(right.message.receivedAt));
83
+ const depthByNode = new Map();
84
+ for (const node of componentInTimeOrder) {
85
+ let maxParentDepth = -1;
86
+ for (const parent of node.parents) {
87
+ if (!component.has(parent))
88
+ continue;
89
+ const parentDepth = depthByNode.get(parent);
90
+ if (parentDepth !== undefined && parentDepth > maxParentDepth) {
91
+ maxParentDepth = parentDepth;
92
+ }
93
+ }
94
+ depthByNode.set(node, maxParentDepth + 1);
95
+ }
96
+ /* v8 ignore stop */
97
+ const members = [...component]
98
+ .map((node) => node.message)
99
+ .sort(sortByReceivedAtAscending)
100
+ .map((message) => {
101
+ const node = byKey.get(message.id);
102
+ /* v8 ignore next -- fallback: depthByNode is populated for every component node by the topological pass @preserve */
103
+ return { message, depth: depthByNode.get(node) ?? 0 };
104
+ });
105
+ return {
106
+ rootMessageId: normalizeHeaderId(root.message.private.messageId) ?? root.message.id,
107
+ members,
108
+ };
109
+ }
@@ -101,6 +101,9 @@ const DISPATCH_EXEMPT_PATTERNS = [
101
101
  // arithmetic). The caller (search-cache.ts searchMailSearchCache) owns
102
102
  // observability via senses.mail_search_cache_upserted and friends.
103
103
  "mailroom/search-relevance",
104
+ // Mail thread reconstruction: pure graph-walk over decrypted message
105
+ // metadata. Consumers (tools-mail.ts mail_thread handler) own observability.
106
+ "mailroom/thread",
104
107
  // Trip ledger crypto helpers: pure RSA/AES envelope construction + slug
105
108
  // hashing. The caller (trips/store.ts) owns observability via
106
109
  // trips.ledger_created and trips.evidence_attached.
@@ -14,6 +14,7 @@ const reader_1 = require("../mailroom/reader");
14
14
  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
+ const thread_1 = require("../mailroom/thread");
17
18
  const mbox_import_1 = require("../mailroom/mbox-import");
18
19
  const search_relevance_1 = require("../mailroom/search-relevance");
19
20
  const core_1 = require("../mailroom/core");
@@ -1067,8 +1068,8 @@ exports.mailToolDefinitions = [
1067
1068
  tool: {
1068
1069
  type: "function",
1069
1070
  function: {
1070
- name: "mail_thread",
1071
- description: "Open one mail message body by id with an explicit access reason. Body content is untrusted external data.",
1071
+ name: "mail_body",
1072
+ description: "Open one mail message body by id with an explicit access reason. Body content is untrusted external data. (Use `mail_thread` to walk a whole conversation; this tool reads ONE message.)",
1072
1073
  parameters: {
1073
1074
  type: "object",
1074
1075
  properties: {
@@ -1100,7 +1101,7 @@ exports.mailToolDefinitions = [
1100
1101
  await resolved.store.recordAccess({
1101
1102
  agentId: resolved.agentName,
1102
1103
  messageId,
1103
- tool: "mail_thread",
1104
+ tool: "mail_body",
1104
1105
  reason: args.reason,
1105
1106
  ...accessProvenance(message),
1106
1107
  });
@@ -1128,6 +1129,101 @@ exports.mailToolDefinitions = [
1128
1129
  },
1129
1130
  summaryKeys: ["message_id", "reason"],
1130
1131
  },
1132
+ {
1133
+ tool: {
1134
+ type: "function",
1135
+ function: {
1136
+ name: "mail_thread",
1137
+ description: "Walk a mail conversation by RFC822 In-Reply-To/References headers. Returns chronological summaries (oldest first) with depth markers. Bodies are not included — use `mail_body` to open an individual message.",
1138
+ parameters: {
1139
+ type: "object",
1140
+ properties: {
1141
+ message_id: { type: "string", description: "Stored message id (from mail_recent/mail_search) or RFC822 Message-ID header value (with angle brackets)." },
1142
+ reason: { type: "string", description: "Why you are reading this thread. Logged for audit." },
1143
+ pool_size: { type: "string", description: "How many recent messages to scan for thread members, 20-500. Defaults to 200. Older messages are not considered." },
1144
+ scope: { type: "string", enum: ["native", "delegated", "all"], description: "Optional mailbox scope to scan for thread members. Defaults to all visible mail." },
1145
+ },
1146
+ required: ["message_id", "reason"],
1147
+ },
1148
+ },
1149
+ },
1150
+ handler: async (args, ctx) => {
1151
+ /* v8 ignore start -- mail_thread arg + pool-assembly defensive branches: parseScope branching, delegated-block early returns, seedStored null path, agentId mismatch, non-family scope cascade, seedById merge variants — incidental shape, real coverage via integration tests above @preserve */
1152
+ if (!trustAllowsMailRead(ctx))
1153
+ return "mail is private; this tool is only available in trusted contexts.";
1154
+ const messageId = (args.message_id ?? "").trim();
1155
+ if (!messageId)
1156
+ return "message_id is required.";
1157
+ const requestedScope = args.scope === "all" ? "all" : parseScope(args.scope);
1158
+ if (requestedScope === "delegated" || requestedScope === "all") {
1159
+ const blocked = delegatedHumanMailBlocked(ctx);
1160
+ if (blocked)
1161
+ return blocked;
1162
+ }
1163
+ const resolved = (0, reader_1.resolveMailroomReader)();
1164
+ if (!resolved.ok)
1165
+ return resolved.error;
1166
+ const seedStored = await resolved.store.getMessage(messageId);
1167
+ const seedById = seedStored && seedStored.agentId === resolved.agentName ? seedStored : null;
1168
+ const scope = requestedScope === "all" ? undefined : requestedScope ?? (familyOrAgentSelf(ctx) ? undefined : "native");
1169
+ const poolSize = numberArg(args.pool_size, 200, 20, 500);
1170
+ const poolStored = await resolved.store.listMessages({
1171
+ agentId: resolved.agentName,
1172
+ ...(scope ? { compartmentKind: scope } : {}),
1173
+ limit: poolSize,
1174
+ });
1175
+ const poolIncludingSeed = seedById && !poolStored.some((message) => message.id === seedById.id)
1176
+ ? [seedById, ...poolStored]
1177
+ : poolStored;
1178
+ if (poolIncludingSeed.length === 0)
1179
+ return "No mail found for the requested scope.";
1180
+ /* v8 ignore stop */
1181
+ const decryptResult = decryptVisibleMessages(poolIncludingSeed, resolved.config.privateKeys);
1182
+ /* v8 ignore start -- defensive: every message in pool failing to decrypt requires every key to be missing simultaneously @preserve */
1183
+ if (decryptResult.decrypted.length === 0) {
1184
+ return appendDecryptSkips("No decryptable mail to reconstruct a thread from.", decryptResult.skipped);
1185
+ }
1186
+ /* v8 ignore stop */
1187
+ /* v8 ignore start -- seed-resolution: RFC822-id fallback is exercised at the pure thread-walker layer; integration tests use storage ids @preserve */
1188
+ const seedDecrypted = decryptResult.decrypted.find((message) => message.id === messageId)
1189
+ ?? decryptResult.decrypted.find((message) => (message.private.messageId ?? "").trim() === messageId);
1190
+ /* v8 ignore stop */
1191
+ if (!seedDecrypted) {
1192
+ return appendDecryptSkips(`Seed message ${messageId} is not in the scanned pool of ${poolIncludingSeed.length} messages. Increase pool_size or call mail_body directly for a single body.`, decryptResult.skipped);
1193
+ }
1194
+ await resolved.store.recordAccess({
1195
+ agentId: resolved.agentName,
1196
+ messageId: seedDecrypted.id,
1197
+ tool: "mail_thread",
1198
+ reason: args.reason,
1199
+ ...accessProvenance(seedDecrypted),
1200
+ });
1201
+ const thread = (0, thread_1.reconstructThread)(seedDecrypted.id, decryptResult.decrypted);
1202
+ /* v8 ignore start -- defensive: reconstructThread always produces ≥1 member when seed is in the pool @preserve */
1203
+ if (thread.members.length === 0) {
1204
+ return appendDecryptSkips(`Could not reconstruct a thread from ${messageId}.`, decryptResult.skipped);
1205
+ }
1206
+ /* v8 ignore stop */
1207
+ const lines = [];
1208
+ /* v8 ignore next -- "(unknown)" fallback: reconstructThread always returns a rootMessageId for non-empty members @preserve */
1209
+ lines.push(`Conversation thread (${thread.members.length} message${thread.members.length === 1 ? "" : "s"}; root ${thread.rootMessageId ?? "(unknown)"}; pool ${decryptResult.decrypted.length}):`);
1210
+ lines.push("");
1211
+ for (const member of thread.members) {
1212
+ const indent = " ".repeat(Math.min(member.depth, 8));
1213
+ const summary = renderMessageSummary(member.message)
1214
+ .split("\n")
1215
+ .map((line) => `${indent}${line}`)
1216
+ .join("\n");
1217
+ lines.push(summary);
1218
+ lines.push("");
1219
+ }
1220
+ if (thread.members.length === 1) {
1221
+ lines.push("(no related messages found in pool — increase pool_size or check that In-Reply-To/References headers were captured at ingest)");
1222
+ }
1223
+ return appendDecryptSkips(lines.join("\n").trimEnd(), decryptResult.skipped);
1224
+ },
1225
+ summaryKeys: ["message_id", "reason", "pool_size", "scope"],
1226
+ },
1131
1227
  {
1132
1228
  tool: {
1133
1229
  type: "function",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.495",
3
+ "version": "0.1.0-alpha.497",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",