@ouro.bot/cli 0.1.0-alpha.313 → 0.1.0-alpha.315

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,18 @@
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.315",
6
+ "changes": [
7
+ "fix(tui): fix startup TUI ANSI overwrite, daemon death detection, and log visibility. Resolves visual corruption from overlapping ANSI escape sequences, improves process death detection reliability, and ensures daemon log output is consistently visible during startup."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.314",
12
+ "changes": [
13
+ "fix(daemon): remove 60s startup TUI timeout and show progress while socket is unavailable. `pollDaemonStartup()` now waits indefinitely until all agents are stable or failed, instead of timing out after 60s and falsely reporting degraded status. Added 'waiting for daemon' spinner with elapsed time and latest daemon log event while the socket is not yet available."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.313",
6
18
  "changes": [
@@ -118,11 +118,51 @@ async function ensureDaemonRunning(deps) {
118
118
  const stability = await (0, startup_tui_1.pollDaemonStartup)({
119
119
  sendCommand: deps.sendCommand,
120
120
  socketPath: deps.socketPath,
121
- writeStdout: deps.writeStdout,
121
+ daemonPid: started.pid ?? null,
122
+ /* v8 ignore next -- thin wrapper: raw process.stdout.write for ANSI cursor control @preserve */
123
+ writeRaw: (text) => process.stdout.write(text),
122
124
  /* v8 ignore next -- thin wrapper: real Date.now() injected for testability @preserve */
123
125
  now: () => Date.now(),
124
126
  /* v8 ignore next -- thin wrapper: real setTimeout injected for testability @preserve */
125
127
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
128
+ /* v8 ignore start -- daemon log tail + pid check: reads real filesystem, tested via deployment @preserve */
129
+ readLatestDaemonEvent: () => {
130
+ try {
131
+ // The daemon writes structured events to daemon.ndjson in the first
132
+ // agent bundle's state/daemon/logs/ directory. Read the last line to
133
+ // surface what it's currently doing (e.g., "starting auto-start agents").
134
+ const bundlesRoot = (0, identity_1.getAgentBundlesRoot)();
135
+ if (!fs.existsSync(bundlesRoot))
136
+ return null;
137
+ const agents = fs.readdirSync(bundlesRoot).filter((d) => d.endsWith(".ouro"));
138
+ for (const agent of agents) {
139
+ const logPath = path.join(bundlesRoot, agent, "state", "daemon", "logs", "daemon.ndjson");
140
+ if (!fs.existsSync(logPath))
141
+ continue;
142
+ const stat = fs.statSync(logPath);
143
+ if (stat.size === 0)
144
+ continue;
145
+ // Only read logs from the last 30 seconds (daemon just started)
146
+ const mtime = stat.mtimeMs;
147
+ if (Date.now() - mtime > 30_000)
148
+ continue;
149
+ const buf = Buffer.alloc(4096);
150
+ const fd = fs.openSync(logPath, "r");
151
+ const readFrom = Math.max(0, stat.size - 4096);
152
+ fs.readSync(fd, buf, 0, 4096, readFrom);
153
+ fs.closeSync(fd);
154
+ const lines = buf.toString("utf-8").trim().split("\n").filter(Boolean);
155
+ const last = lines[lines.length - 1];
156
+ if (!last)
157
+ continue;
158
+ const parsed = JSON.parse(last);
159
+ return parsed.message ?? null;
160
+ }
161
+ }
162
+ catch { /* best effort */ }
163
+ return null;
164
+ },
165
+ /* v8 ignore stop */
126
166
  });
127
167
  return {
128
168
  alreadyRunning: false,
@@ -13,6 +13,7 @@
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.assessStability = assessStability;
15
15
  exports.renderStartupProgress = renderStartupProgress;
16
+ exports.renderWaitingForDaemon = renderWaitingForDaemon;
16
17
  exports.pollDaemonStartup = pollDaemonStartup;
17
18
  const cli_render_1 = require("./cli-render");
18
19
  const runtime_1 = require("../../nerves/runtime");
@@ -20,7 +21,6 @@ const runtime_1 = require("../../nerves/runtime");
20
21
  const SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
21
22
  const STABILITY_THRESHOLD_MS = 5_000;
22
23
  const POLL_INTERVAL_MS = 500;
23
- const MAX_POLL_MS = 60_000;
24
24
  // ── ANSI helpers ──
25
25
  const RESET = "\x1b[0m";
26
26
  const BOLD = "\x1b[1m";
@@ -67,19 +67,13 @@ function assessStability(payload, now) {
67
67
  /**
68
68
  * Build an ANSI string for in-place terminal display during polling.
69
69
  * Uses cursor-up and line-clear escapes to overwrite previous output.
70
- *
71
- * @param payload Current daemon status
72
- * @param elapsed Milliseconds since polling started
73
- * @param prevLineCount Number of lines written in the previous render (0 on first)
74
70
  */
75
71
  function renderStartupProgress(payload, elapsed, prevLineCount = 0) {
76
72
  const frameIndex = Math.floor(elapsed / 100) % SPINNER_FRAMES.length;
77
73
  const spinner = SPINNER_FRAMES[frameIndex];
78
74
  const lines = [];
79
- // Header line
80
75
  const elapsedSec = (elapsed / 1000).toFixed(1);
81
76
  lines.push(`${spinner} ${BOLD}waiting for agents${RESET} ${DIM}(${elapsedSec}s)${RESET}`);
82
- // Per-worker status lines
83
77
  for (const worker of payload.workers) {
84
78
  const statusColor = worker.status === "running" ? GREEN
85
79
  : worker.status === "crashed" ? RED
@@ -87,7 +81,27 @@ function renderStartupProgress(payload, elapsed, prevLineCount = 0) {
87
81
  const statusText = `${statusColor}${worker.status}${RESET}`;
88
82
  lines.push(` ${worker.agent}/${worker.worker}: ${statusText}`);
89
83
  }
90
- // Build output with cursor-up to overwrite previous lines
84
+ let output = "";
85
+ if (prevLineCount > 0) {
86
+ output += `\x1b[${prevLineCount}A`;
87
+ }
88
+ for (const line of lines) {
89
+ output += `\x1b[2K${line}\n`;
90
+ }
91
+ return output;
92
+ }
93
+ /**
94
+ * Render a pre-socket status line showing what the daemon is doing.
95
+ */
96
+ function renderWaitingForDaemon(elapsed, latestEvent, prevLineCount = 0) {
97
+ const elapsedSec = (elapsed / 1000).toFixed(1);
98
+ const frameIndex = Math.floor(elapsed / 100) % SPINNER_FRAMES.length;
99
+ const spinner = SPINNER_FRAMES[frameIndex];
100
+ const lines = [];
101
+ lines.push(`${spinner} ${BOLD}waiting for daemon${RESET} ${DIM}(${elapsedSec}s)${RESET}`);
102
+ if (latestEvent) {
103
+ lines.push(` ${DIM}${latestEvent}${RESET}`);
104
+ }
91
105
  let output = "";
92
106
  if (prevLineCount > 0) {
93
107
  output += `\x1b[${prevLineCount}A`;
@@ -120,51 +134,70 @@ function renderFinalSummary(result) {
120
134
  /**
121
135
  * Poll the daemon's status socket until all agents are stable or definitively
122
136
  * failed, rendering real-time progress to the terminal.
137
+ *
138
+ * Detects daemon process death: if the spawned PID is no longer alive and the
139
+ * socket never came up, reports the failure immediately instead of spinning.
123
140
  */
124
141
  async function pollDaemonStartup(deps) {
125
142
  const startTime = deps.now();
126
143
  let prevLineCount = 0;
144
+ const isAlive = deps.isProcessAlive ?? defaultIsProcessAlive;
127
145
  (0, runtime_1.emitNervesEvent)({
128
146
  component: "daemon",
129
147
  event: "daemon.startup_poll_start",
130
148
  message: "beginning startup stability polling",
131
- meta: { socketPath: deps.socketPath },
149
+ meta: { socketPath: deps.socketPath, daemonPid: deps.daemonPid },
132
150
  });
133
151
  while (true) {
134
152
  const now = deps.now();
135
153
  const elapsed = now - startTime;
136
- if (elapsed > MAX_POLL_MS) {
137
- (0, runtime_1.emitNervesEvent)({
138
- level: "warn",
139
- component: "daemon",
140
- event: "daemon.startup_poll_timeout",
141
- message: "startup polling timed out",
142
- meta: { elapsedMs: elapsed },
143
- });
144
- // Timeout: treat any unresolved agents as degraded
145
- return { stable: [], degraded: [] };
146
- }
147
154
  let payload = null;
148
155
  try {
149
156
  const response = await deps.sendCommand(deps.socketPath, { kind: "daemon.status" });
150
157
  payload = (0, cli_render_1.parseStatusPayload)(response.data);
151
158
  }
152
159
  catch {
153
- // Socket not yet available — will retry after sleep
160
+ // Socket not yet available — check if the daemon process is still alive
161
+ if (deps.daemonPid !== null && !isAlive(deps.daemonPid)) {
162
+ const latestEvent = deps.readLatestDaemonEvent?.() ?? null;
163
+ const errorMsg = latestEvent ?? "daemon process died during startup";
164
+ (0, runtime_1.emitNervesEvent)({
165
+ level: "error",
166
+ component: "daemon",
167
+ event: "daemon.startup_process_died",
168
+ message: "daemon process died before socket came up",
169
+ meta: { pid: deps.daemonPid, lastEvent: latestEvent },
170
+ });
171
+ // Clear the waiting line
172
+ if (prevLineCount > 0) {
173
+ let clear = `\x1b[${prevLineCount}A`;
174
+ for (let i = 0; i < prevLineCount; i++)
175
+ clear += `\x1b[2K\n`;
176
+ deps.writeRaw(clear);
177
+ }
178
+ return {
179
+ stable: [],
180
+ degraded: [{ agent: "daemon", errorReason: errorMsg, fixHint: "check daemon logs or run `ouro doctor`" }],
181
+ };
182
+ }
183
+ // Show what the daemon is doing from its log
184
+ const latestEvent = deps.readLatestDaemonEvent?.() ?? null;
185
+ const output = renderWaitingForDaemon(elapsed, latestEvent, prevLineCount);
186
+ deps.writeRaw(output);
187
+ prevLineCount = latestEvent ? 2 : 1;
154
188
  }
155
189
  if (payload) {
156
190
  const output = renderStartupProgress(payload, elapsed, prevLineCount);
157
- deps.writeStdout(output);
158
- prevLineCount = payload.workers.length + 1; // header + per-worker lines
191
+ deps.writeRaw(output);
192
+ prevLineCount = payload.workers.length + 1;
159
193
  const assessment = assessStability(payload, now);
160
194
  if (assessment.resolved) {
161
195
  const result = {
162
196
  stable: assessment.stable,
163
197
  degraded: assessment.degraded,
164
198
  };
165
- // Clear progress lines and render final summary
166
199
  const summary = renderFinalSummary(result);
167
- deps.writeStdout(summary);
200
+ deps.writeRaw(summary);
168
201
  (0, runtime_1.emitNervesEvent)({
169
202
  component: "daemon",
170
203
  event: "daemon.startup_poll_end",
@@ -181,3 +214,14 @@ async function pollDaemonStartup(deps) {
181
214
  await deps.sleep(POLL_INTERVAL_MS);
182
215
  }
183
216
  }
217
+ /* v8 ignore start -- process liveness check: uses real process.kill(0), tested via deployment @preserve */
218
+ function defaultIsProcessAlive(pid) {
219
+ try {
220
+ process.kill(pid, 0);
221
+ return true;
222
+ }
223
+ catch {
224
+ return false;
225
+ }
226
+ }
227
+ /* v8 ignore stop */
@@ -68,6 +68,7 @@ const outlook_types_1 = require("./outlook-types");
68
68
  const presence_1 = require("../../arc/presence");
69
69
  const cares_1 = require("../../arc/cares");
70
70
  const episodes_1 = require("../../arc/episodes");
71
+ const session_events_1 = require("../session-events");
71
72
  const LIVE_TASK_STATUSES = ["processing", "validating", "collaborating", "blocked"];
72
73
  const ACTIVE_CODING_STATUSES = new Set(["spawning", "running", "waiting_input", "stalled"]);
73
74
  const BLOCKED_CODING_STATUSES = new Set(["waiting_input", "stalled"]);
@@ -378,14 +379,18 @@ function readOutlookMachineState(options = {}) {
378
379
  agents: agentStates.map(summarizeAgent),
379
380
  };
380
381
  }
382
+ // ---------------------------------------------------------------------------
383
+ // Session inventory — enumerate all sessions with summary metadata
384
+ // ---------------------------------------------------------------------------
381
385
  /* v8 ignore start — session envelope parsing utilities */
382
386
  function parseSessionUsage(raw) {
383
- if (!raw)
387
+ if (!raw || typeof raw !== "object")
384
388
  return null;
385
- const inputTokens = typeof raw.input_tokens === "number" ? raw.input_tokens : 0;
386
- const outputTokens = typeof raw.output_tokens === "number" ? raw.output_tokens : 0;
387
- const reasoningTokens = typeof raw.reasoning_tokens === "number" ? raw.reasoning_tokens : 0;
388
- const totalTokens = typeof raw.total_tokens === "number" ? raw.total_tokens : 0;
389
+ const record = raw;
390
+ const inputTokens = typeof record.input_tokens === "number" ? record.input_tokens : 0;
391
+ const outputTokens = typeof record.output_tokens === "number" ? record.output_tokens : 0;
392
+ const reasoningTokens = typeof record.reasoning_tokens === "number" ? record.reasoning_tokens : 0;
393
+ const totalTokens = typeof record.total_tokens === "number" ? record.total_tokens : 0;
389
394
  if (inputTokens === 0 && outputTokens === 0 && totalTokens === 0)
390
395
  return null;
391
396
  return { input_tokens: inputTokens, output_tokens: outputTokens, reasoning_tokens: reasoningTokens, total_tokens: totalTokens };
@@ -393,33 +398,29 @@ function parseSessionUsage(raw) {
393
398
  function parseSessionContinuity(raw) {
394
399
  if (!raw)
395
400
  return null;
396
- return {
397
- mustResolveBeforeHandoff: raw.mustResolveBeforeHandoff === true,
398
- lastFriendActivityAt: typeof raw.lastFriendActivityAt === "string" ? raw.lastFriendActivityAt : null,
401
+ if (typeof raw !== "object")
402
+ return null;
403
+ const record = raw;
404
+ const continuity = {
405
+ mustResolveBeforeHandoff: record.mustResolveBeforeHandoff === true,
406
+ lastFriendActivityAt: typeof record.lastFriendActivityAt === "string" ? record.lastFriendActivityAt : null,
399
407
  };
408
+ if (!continuity.mustResolveBeforeHandoff && continuity.lastFriendActivityAt === null)
409
+ return null;
410
+ return continuity;
400
411
  }
401
- function extractContent(message) {
402
- if (typeof message.content === "string")
403
- return message.content;
404
- return null;
412
+ function extractContent(event) {
413
+ if (!event)
414
+ return null;
415
+ const text = (0, session_events_1.extractEventText)(event);
416
+ return text.length > 0 ? text : null;
405
417
  }
406
- function extractToolCallNames(message) {
407
- const toolCalls = message.tool_calls;
408
- if (!Array.isArray(toolCalls))
418
+ function extractToolCallNames(event) {
419
+ if (!event)
409
420
  return [];
410
- return toolCalls
411
- .map((call) => {
412
- if (call && typeof call === "object" && "function" in call) {
413
- const fn = call.function;
414
- if (fn && typeof fn === "object" && "name" in fn) {
415
- return typeof fn.name === "string" ? fn.name : null;
416
- }
417
- }
418
- /* v8 ignore start */
419
- return null;
420
- /* v8 ignore stop */
421
- })
422
- .filter((name) => name !== null);
421
+ return event.toolCalls
422
+ .map((call) => call.function.name)
423
+ .filter((name) => typeof name === "string" && name.length > 0);
423
424
  }
424
425
  /* v8 ignore stop */
425
426
  function estimateTokenCount(messages) {
@@ -428,21 +429,13 @@ function estimateTokenCount(messages) {
428
429
  const content = extractContent(msg);
429
430
  if (content)
430
431
  charCount += content.length;
431
- const toolCalls = msg.tool_calls;
432
- if (Array.isArray(toolCalls)) {
433
- charCount += JSON.stringify(toolCalls).length;
434
- }
432
+ if (msg.toolCalls.length > 0)
433
+ charCount += JSON.stringify(msg.toolCalls).length;
435
434
  }
436
435
  return Math.ceil(charCount / 4);
437
436
  }
438
437
  function readSessionEnvelope(sessionPath) {
439
- try {
440
- const raw = fs.readFileSync(sessionPath, "utf-8");
441
- return JSON.parse(raw);
442
- }
443
- catch {
444
- return null;
445
- }
438
+ return (0, session_events_1.loadSessionEnvelopeFile)(sessionPath);
446
439
  }
447
440
  /* v8 ignore start — filesystem traversal with defensive isDirectory checks */
448
441
  function resolveAllSessionPaths(sessionsDir) {
@@ -516,18 +509,26 @@ function readSessionInventory(agentName, options = {}) {
516
509
  if (friendId === "self" && channel === "inner")
517
510
  continue;
518
511
  const envelope = readSessionEnvelope(sessionPath);
519
- const messages = Array.isArray(envelope?.messages) ? envelope.messages : [];
512
+ const events = envelope?.events ?? [];
513
+ const chronology = (0, session_events_1.deriveSessionChronology)(events);
520
514
  const lastUsage = parseSessionUsage(envelope?.lastUsage);
521
515
  const continuity = parseSessionContinuity(envelope?.state);
522
- const lastActivityAt = continuity?.lastFriendActivityAt ?? safeFileMtime(sessionPath) ?? now.toISOString();
523
- const activitySource = continuity?.lastFriendActivityAt ? "friend-facing" : "mtime-fallback";
524
- const userMessages = messages.filter((m) => m.role === "user");
525
- const assistantMessages = messages.filter((m) => m.role === "assistant");
516
+ const hasObservedEventTiming = events.some((event) => event.time.authoredAt !== null || event.time.observedAt !== null);
517
+ const lastActivityAt = hasObservedEventTiming
518
+ ? (chronology.lastActivityAt ?? continuity?.lastFriendActivityAt ?? safeFileMtime(sessionPath) ?? now.toISOString())
519
+ : (continuity?.lastFriendActivityAt ?? safeFileMtime(sessionPath) ?? now.toISOString());
520
+ const activitySource = hasObservedEventTiming && chronology.lastActivityAt
521
+ ? "event-timeline"
522
+ : continuity?.lastFriendActivityAt
523
+ ? "friend-facing"
524
+ : "mtime-fallback";
525
+ const userMessages = events.filter((m) => m.role === "user");
526
+ const assistantMessages = events.filter((m) => m.role === "assistant");
526
527
  const lastUser = userMessages.length > 0 ? userMessages[userMessages.length - 1] : null;
527
528
  const lastAssistant = assistantMessages.length > 0 ? assistantMessages[assistantMessages.length - 1] : null;
528
529
  const latestToolCallNames = [];
529
- for (let i = messages.length - 1; i >= 0; i--) {
530
- const names = extractToolCallNames(messages[i]);
530
+ for (let i = events.length - 1; i >= 0; i--) {
531
+ const names = extractToolCallNames(events[i]);
531
532
  if (names.length > 0) {
532
533
  latestToolCallNames.push(...names);
533
534
  break;
@@ -535,7 +536,7 @@ function readSessionInventory(agentName, options = {}) {
535
536
  }
536
537
  const friendName = resolveFriendName(friendsDir, friendId);
537
538
  // Derive reply state from message pattern
538
- const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null;
539
+ const lastMsg = events.length > 0 ? events[events.length - 1] : null;
539
540
  const mustResolve = continuity?.mustResolveBeforeHandoff === true;
540
541
  let replyState = "idle";
541
542
  if (mustResolve) {
@@ -544,7 +545,7 @@ function readSessionInventory(agentName, options = {}) {
544
545
  else if (lastMsg?.role === "user") {
545
546
  replyState = "needs-reply";
546
547
  }
547
- else if (messages.length > 0) {
548
+ else if (events.length > 0) {
548
549
  replyState = "monitoring";
549
550
  }
550
551
  items.push({
@@ -556,13 +557,13 @@ function readSessionInventory(agentName, options = {}) {
556
557
  lastActivityAt,
557
558
  activitySource,
558
559
  replyState,
559
- messageCount: messages.length,
560
+ messageCount: events.length,
560
561
  lastUsage,
561
562
  continuity,
562
- latestUserExcerpt: truncateExcerpt(extractContent(lastUser ?? {})),
563
- latestAssistantExcerpt: truncateExcerpt(extractContent(lastAssistant ?? {})),
563
+ latestUserExcerpt: truncateExcerpt(extractContent(lastUser)),
564
+ latestAssistantExcerpt: truncateExcerpt(extractContent(lastAssistant)),
564
565
  latestToolCallNames,
565
- estimatedTokens: messages.length > 0 ? estimateTokenCount(messages) : null,
566
+ estimatedTokens: events.length > 0 ? estimateTokenCount(events) : null,
566
567
  });
567
568
  }
568
569
  items.sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt));
@@ -606,34 +607,10 @@ function readSessionTranscript(agentName, friendId, channel, key, options = {})
606
607
  const envelope = readSessionEnvelope(sessionPath);
607
608
  if (!envelope)
608
609
  return null;
609
- const rawMessages = Array.isArray(envelope.messages) ? envelope.messages : [];
610
+ const rawMessages = envelope.events;
610
611
  const friendsDir = path.join(agentRoot, "friends");
611
612
  const friendName = resolveFriendName(friendsDir, friendId);
612
- const messages = rawMessages.map((msg, index) => {
613
- const role = typeof msg.role === "string" ? msg.role : "user";
614
- const content = extractContent(msg);
615
- const result = { index, role, content };
616
- if (typeof msg.name === "string")
617
- result.name = msg.name;
618
- if (typeof msg.tool_call_id === "string")
619
- result.tool_call_id = msg.tool_call_id;
620
- if (Array.isArray(msg.tool_calls)) {
621
- result.tool_calls = msg.tool_calls
622
- .filter((call) => call != null && typeof call === "object")
623
- .map((call) => {
624
- const fn = call.function;
625
- return {
626
- id: typeof call.id === "string" ? call.id : "",
627
- type: typeof call.type === "string" ? call.type : "function",
628
- function: {
629
- name: typeof fn?.name === "string" ? fn.name : "unknown",
630
- arguments: typeof fn?.arguments === "string" ? fn.arguments : JSON.stringify(fn?.arguments ?? ""),
631
- },
632
- };
633
- });
634
- }
635
- return result;
636
- });
613
+ const messages = rawMessages;
637
614
  return {
638
615
  friendId,
639
616
  friendName,
@@ -1,7 +1,27 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OUTLOOK_DEFAULT_PORT = exports.OUTLOOK_DEFAULT_INNER_VISIBILITY = exports.OUTLOOK_RELEASE_INTERACTION_MODEL = exports.OUTLOOK_PRODUCT_NAME = void 0;
4
+ exports.getOutlookTranscriptMessageText = getOutlookTranscriptMessageText;
5
+ exports.getOutlookTranscriptTimestamp = getOutlookTranscriptTimestamp;
4
6
  exports.OUTLOOK_PRODUCT_NAME = "Ouro Outlook";
5
7
  exports.OUTLOOK_RELEASE_INTERACTION_MODEL = "read-only";
6
8
  exports.OUTLOOK_DEFAULT_INNER_VISIBILITY = "summary";
7
9
  exports.OUTLOOK_DEFAULT_PORT = 6876;
10
+ function transcriptContentText(content) {
11
+ if (typeof content === "string")
12
+ return content;
13
+ if (!Array.isArray(content))
14
+ return "";
15
+ return content
16
+ .map((part) => (part.type === "text" && typeof part.text === "string"
17
+ ? part.text
18
+ : ""))
19
+ .filter((text) => text.length > 0)
20
+ .join("");
21
+ }
22
+ function getOutlookTranscriptMessageText(message) {
23
+ return transcriptContentText(message.content);
24
+ }
25
+ function getOutlookTranscriptTimestamp(message) {
26
+ return message.time.authoredAt ?? message.time.observedAt ?? message.time.recordedAt;
27
+ }
@@ -39,6 +39,7 @@ const fs = __importStar(require("fs"));
39
39
  const path = __importStar(require("path"));
40
40
  const runtime_1 = require("../nerves/runtime");
41
41
  const config_1 = require("./config");
42
+ const session_events_1 = require("./session-events");
42
43
  const DEFAULT_ACTIVE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
43
44
  function activityPriority(source) {
44
45
  return source === "friend-facing" ? 0 : 1;
@@ -63,28 +64,42 @@ function parseFriendActivity(sessionPath) {
63
64
  catch {
64
65
  return null;
65
66
  }
66
- try {
67
- const raw = fs.readFileSync(sessionPath, "utf-8");
68
- const parsed = JSON.parse(raw);
69
- const explicit = parsed?.state?.lastFriendActivityAt;
70
- if (typeof explicit === "string") {
71
- const parsedMs = Date.parse(explicit);
72
- if (Number.isFinite(parsedMs)) {
73
- return {
74
- lastActivityMs: parsedMs,
75
- lastActivityAt: new Date(parsedMs).toISOString(),
76
- activitySource: "friend-facing",
77
- };
78
- }
67
+ const envelope = (0, session_events_1.loadSessionEnvelopeFile)(sessionPath);
68
+ const chronology = envelope ? (0, session_events_1.deriveSessionChronology)(envelope.events) : null;
69
+ const explicit = envelope?.state.lastFriendActivityAt;
70
+ if (typeof explicit === "string") {
71
+ const parsedMs = Date.parse(explicit);
72
+ if (Number.isFinite(parsedMs)) {
73
+ return {
74
+ lastActivityMs: parsedMs,
75
+ lastActivityAt: new Date(parsedMs).toISOString(),
76
+ activitySource: "friend-facing",
77
+ lastInboundAt: chronology?.lastInboundAt ?? null,
78
+ lastOutboundAt: chronology?.lastOutboundAt ?? null,
79
+ unansweredInboundCount: chronology?.unansweredInboundCount ?? 0,
80
+ };
79
81
  }
80
82
  }
81
- catch {
82
- // fall back to file mtime below
83
+ if (chronology?.lastInboundAt) {
84
+ const parsedMs = Date.parse(chronology.lastInboundAt);
85
+ if (Number.isFinite(parsedMs)) {
86
+ return {
87
+ lastActivityMs: parsedMs,
88
+ lastActivityAt: new Date(parsedMs).toISOString(),
89
+ activitySource: "friend-facing",
90
+ lastInboundAt: chronology.lastInboundAt,
91
+ lastOutboundAt: chronology.lastOutboundAt,
92
+ unansweredInboundCount: chronology.unansweredInboundCount,
93
+ };
94
+ }
83
95
  }
84
96
  return {
85
97
  lastActivityMs: mtimeMs,
86
98
  lastActivityAt: new Date(mtimeMs).toISOString(),
87
99
  activitySource: "mtime-fallback",
100
+ lastInboundAt: chronology?.lastInboundAt ?? null,
101
+ lastOutboundAt: chronology?.lastOutboundAt ?? null,
102
+ unansweredInboundCount: chronology?.unansweredInboundCount ?? 0,
88
103
  };
89
104
  }
90
105
  function listSessionActivity(query) {
@@ -151,6 +166,9 @@ function listSessionActivity(query) {
151
166
  lastActivityAt: activity.lastActivityAt,
152
167
  lastActivityMs: activity.lastActivityMs,
153
168
  activitySource: activity.activitySource,
169
+ lastInboundAt: activity.lastInboundAt,
170
+ lastOutboundAt: activity.lastOutboundAt,
171
+ unansweredInboundCount: activity.unansweredInboundCount,
154
172
  });
155
173
  }
156
174
  }