@ouro.bot/cli 0.1.0-alpha.49 → 0.1.0-alpha.50

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,13 @@
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.50",
6
+ "changes": [
7
+ "Delegated inner work can now proactively surface its completion back into the active BlueBubbles session instead of waiting for a later inbox drain.",
8
+ "Session recall now falls back to the raw transcript when summarization fails, so bridge attachment and cross-session inspection stay truthful instead of claiming the session is missing."
9
+ ]
10
+ },
4
11
  {
5
12
  "version": "0.1.0-alpha.49",
6
13
  "changes": [
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.suggestBridgeForActiveWork = suggestBridgeForActiveWork;
4
+ exports.buildActiveWorkFrame = buildActiveWorkFrame;
5
+ exports.formatActiveWorkFrame = formatActiveWorkFrame;
6
+ const runtime_1 = require("../nerves/runtime");
7
+ const state_machine_1 = require("./bridges/state-machine");
8
+ function activityPriority(source) {
9
+ return source === "friend-facing" ? 0 : 1;
10
+ }
11
+ function compareActivity(a, b) {
12
+ const sourceDiff = activityPriority(a.activitySource) - activityPriority(b.activitySource);
13
+ if (sourceDiff !== 0)
14
+ return sourceDiff;
15
+ return b.lastActivityMs - a.lastActivityMs;
16
+ }
17
+ function summarizeLiveTasks(taskBoard) {
18
+ const live = [
19
+ ...taskBoard.byStatus.processing,
20
+ ...taskBoard.byStatus.validating,
21
+ ...taskBoard.byStatus.collaborating,
22
+ ];
23
+ return [...new Set(live)];
24
+ }
25
+ function isActiveBridge(bridge) {
26
+ return bridge.lifecycle === "active";
27
+ }
28
+ function hasSharedObligationPressure(input) {
29
+ return (typeof input.currentObligation === "string"
30
+ && input.currentObligation.trim().length > 0) || input.mustResolveBeforeHandoff
31
+ || summarizeLiveTasks(input.taskBoard).length > 0;
32
+ }
33
+ function suggestBridgeForActiveWork(input) {
34
+ const candidateSessions = input.friendSessions
35
+ .filter((session) => !input.currentSession
36
+ || session.friendId !== input.currentSession.friendId
37
+ || session.channel !== input.currentSession.channel
38
+ || session.key !== input.currentSession.key)
39
+ .sort(compareActivity);
40
+ const targetSession = candidateSessions[0] ?? null;
41
+ if (!targetSession || !hasSharedObligationPressure(input)) {
42
+ return null;
43
+ }
44
+ const activeBridge = input.bridges.find(isActiveBridge) ?? null;
45
+ if (activeBridge) {
46
+ const alreadyAttached = activeBridge.attachedSessions.some((session) => session.friendId === targetSession.friendId
47
+ && session.channel === targetSession.channel
48
+ && session.key === targetSession.key);
49
+ if (alreadyAttached) {
50
+ return null;
51
+ }
52
+ return {
53
+ kind: "attach-existing",
54
+ bridgeId: activeBridge.id,
55
+ targetSession,
56
+ reason: "same-friend-shared-work",
57
+ };
58
+ }
59
+ return {
60
+ kind: "begin-new",
61
+ targetSession,
62
+ objectiveHint: input.currentObligation?.trim() || "keep this shared work aligned",
63
+ reason: "same-friend-shared-work",
64
+ };
65
+ }
66
+ function formatSessionLabel(session) {
67
+ return `${session.channel}/${session.key}`;
68
+ }
69
+ function buildActiveWorkFrame(input) {
70
+ const friendSessions = input.currentSession
71
+ ? input.friendActivity
72
+ .filter((entry) => entry.friendId === input.currentSession?.friendId)
73
+ .sort(compareActivity)
74
+ : [];
75
+ const liveTaskNames = summarizeLiveTasks(input.taskBoard);
76
+ const activeBridgePresent = input.bridges.some(isActiveBridge);
77
+ const centerOfGravity = activeBridgePresent
78
+ ? "shared-work"
79
+ : (input.inner.status === "running" || input.inner.hasPending || input.mustResolveBeforeHandoff)
80
+ ? "inward-work"
81
+ : "local-turn";
82
+ const frame = {
83
+ currentSession: input.currentSession ?? null,
84
+ currentObligation: input.currentObligation?.trim() || null,
85
+ mustResolveBeforeHandoff: input.mustResolveBeforeHandoff,
86
+ centerOfGravity,
87
+ inner: input.inner,
88
+ bridges: input.bridges,
89
+ taskPressure: {
90
+ compactBoard: input.taskBoard.compact,
91
+ liveTaskNames,
92
+ activeBridges: input.taskBoard.activeBridges,
93
+ },
94
+ friendActivity: {
95
+ freshestForCurrentFriend: friendSessions[0] ?? null,
96
+ otherLiveSessionsForCurrentFriend: friendSessions,
97
+ },
98
+ bridgeSuggestion: suggestBridgeForActiveWork({
99
+ currentSession: input.currentSession,
100
+ currentObligation: input.currentObligation,
101
+ mustResolveBeforeHandoff: input.mustResolveBeforeHandoff,
102
+ bridges: input.bridges,
103
+ taskBoard: input.taskBoard,
104
+ friendSessions,
105
+ }),
106
+ };
107
+ (0, runtime_1.emitNervesEvent)({
108
+ component: "engine",
109
+ event: "engine.active_work_build",
110
+ message: "built shared active-work frame",
111
+ meta: {
112
+ centerOfGravity: frame.centerOfGravity,
113
+ friendId: frame.currentSession?.friendId ?? null,
114
+ bridges: frame.bridges.length,
115
+ liveTasks: frame.taskPressure.liveTaskNames.length,
116
+ liveSessions: frame.friendActivity.otherLiveSessionsForCurrentFriend.length,
117
+ hasBridgeSuggestion: frame.bridgeSuggestion !== null,
118
+ },
119
+ });
120
+ return frame;
121
+ }
122
+ function formatActiveWorkFrame(frame) {
123
+ const lines = ["## active work"];
124
+ if (frame.currentSession) {
125
+ lines.push(`current session: ${formatSessionLabel(frame.currentSession)}`);
126
+ }
127
+ lines.push(`center: ${frame.centerOfGravity}`);
128
+ if (typeof frame.currentObligation === "string" && frame.currentObligation.trim().length > 0) {
129
+ lines.push(`obligation: ${frame.currentObligation.trim()}`);
130
+ }
131
+ if (frame.mustResolveBeforeHandoff) {
132
+ lines.push("handoff pressure: must resolve before handoff");
133
+ }
134
+ const innerStatus = frame.inner?.status ?? "idle";
135
+ const innerHasPending = frame.inner?.hasPending === true;
136
+ lines.push(`inner status: ${innerStatus}${innerHasPending ? " (pending queued)" : ""}`);
137
+ if ((frame.taskPressure?.liveTaskNames ?? []).length > 0) {
138
+ lines.push(`live tasks: ${frame.taskPressure.liveTaskNames.join(", ")}`);
139
+ }
140
+ if ((frame.bridges ?? []).length > 0) {
141
+ const bridgeLabels = frame.bridges.map((bridge) => `${bridge.id} [${(0, state_machine_1.bridgeStateLabel)(bridge)}]`);
142
+ lines.push(`bridges: ${bridgeLabels.join(", ")}`);
143
+ }
144
+ if (frame.friendActivity?.freshestForCurrentFriend) {
145
+ lines.push(`freshest friend-facing session: ${formatSessionLabel(frame.friendActivity.freshestForCurrentFriend)}`);
146
+ }
147
+ if (frame.bridgeSuggestion) {
148
+ if (frame.bridgeSuggestion.kind === "attach-existing") {
149
+ lines.push(`suggested bridge: attach ${frame.bridgeSuggestion.bridgeId} -> ${formatSessionLabel(frame.bridgeSuggestion.targetSession)}`);
150
+ }
151
+ else {
152
+ lines.push(`suggested bridge: begin -> ${formatSessionLabel(frame.bridgeSuggestion.targetSession)}`);
153
+ lines.push(`bridge objective hint: ${frame.bridgeSuggestion.objectiveHint}`);
154
+ }
155
+ }
156
+ return lines.join("\n");
157
+ }
@@ -74,6 +74,26 @@ function ensureRunnable(bridge, now, store) {
74
74
  }
75
75
  return bridge;
76
76
  }
77
+ function sessionMatches(left, right) {
78
+ return left.friendId === right.friendId && left.channel === right.channel && left.key === right.key;
79
+ }
80
+ function hasAttachedSessionActivity(bridge, sessionActivity) {
81
+ return sessionActivity.some((activity) => activity.channel !== "inner"
82
+ && bridge.attachedSessions.some((session) => sessionMatches(activity, session)));
83
+ }
84
+ function hasLiveTaskStatus(bridge, taskBoard) {
85
+ const taskName = bridge.task?.taskName;
86
+ if (!taskName)
87
+ return false;
88
+ return (taskBoard.byStatus.processing.includes(taskName)
89
+ || taskBoard.byStatus.collaborating.includes(taskName)
90
+ || taskBoard.byStatus.validating.includes(taskName));
91
+ }
92
+ function isCurrentSessionAttached(bridge, currentSession) {
93
+ if (!currentSession)
94
+ return false;
95
+ return bridge.attachedSessions.some((session) => sessionMatches(session, currentSession));
96
+ }
77
97
  function createBridgeManager(options = {}) {
78
98
  const store = options.store ?? (0, store_1.createBridgeStore)();
79
99
  const now = options.now ?? (() => new Date().toISOString());
@@ -165,6 +185,23 @@ function createBridgeManager(options = {}) {
165
185
  return store.findBySession(session)
166
186
  .filter((bridge) => bridge.lifecycle !== "completed" && bridge.lifecycle !== "cancelled");
167
187
  },
188
+ reconcileLifecycles(input) {
189
+ return store.list().map((bridge) => {
190
+ const nextState = (0, state_machine_1.reconcileBridgeState)(bridge, {
191
+ hasAttachedSessionActivity: hasAttachedSessionActivity(bridge, input.sessionActivity),
192
+ hasLiveTask: hasLiveTaskStatus(bridge, input.taskBoard),
193
+ currentSessionAttached: isCurrentSessionAttached(bridge, input.currentSession),
194
+ });
195
+ if (nextState.lifecycle === bridge.lifecycle && nextState.runtime === bridge.runtime) {
196
+ return bridge;
197
+ }
198
+ return save({
199
+ ...bridge,
200
+ ...nextState,
201
+ updatedAt: now(),
202
+ });
203
+ });
204
+ },
168
205
  promoteBridgeToTask(bridgeId, input = {}) {
169
206
  const bridge = requireBridge(bridgeId);
170
207
  assertBridgeMutable(bridge, "promote");
@@ -9,6 +9,7 @@ exports.advanceBridgeAfterTurn = advanceBridgeAfterTurn;
9
9
  exports.suspendBridge = suspendBridge;
10
10
  exports.completeBridge = completeBridge;
11
11
  exports.cancelBridge = cancelBridge;
12
+ exports.reconcileBridgeState = reconcileBridgeState;
12
13
  const runtime_1 = require("../../nerves/runtime");
13
14
  function transition(state, next, action) {
14
15
  (0, runtime_1.emitNervesEvent)({
@@ -113,3 +114,22 @@ function cancelBridge(state) {
113
114
  }
114
115
  return transition(state, { lifecycle: "cancelled", runtime: "idle" }, "cancel");
115
116
  }
117
+ function reconcileBridgeState(state, input) {
118
+ if (state.lifecycle === "completed" || state.lifecycle === "cancelled") {
119
+ return state;
120
+ }
121
+ if (state.runtime !== "idle") {
122
+ return state;
123
+ }
124
+ const hasLiveSignal = input.hasAttachedSessionActivity || input.hasLiveTask || input.currentSessionAttached;
125
+ if (state.lifecycle === "suspended") {
126
+ return hasLiveSignal ? activateBridge(state) : state;
127
+ }
128
+ if (state.lifecycle === "forming") {
129
+ return hasLiveSignal ? activateBridge(state) : suspendBridge(state);
130
+ }
131
+ if (state.lifecycle === "active") {
132
+ return hasLiveSignal ? state : suspendBridge(state);
133
+ }
134
+ return state;
135
+ }
@@ -359,6 +359,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
359
359
  let overflowRetried = false;
360
360
  let retryCount = 0;
361
361
  let outcome = "complete";
362
+ let completion;
362
363
  let sawSteeringFollowUp = false;
363
364
  let mustResolveBeforeHandoffActive = options?.mustResolveBeforeHandoff === true;
364
365
  // Prevent MaxListenersExceeded warning — each iteration adds a listener
@@ -453,6 +454,10 @@ async function runAgent(messages, callbacks, channel, signal, options) {
453
454
  const validClosure = answer != null
454
455
  && (!mustResolveBeforeHandoffActive || validDirectReply || validTerminalIntent);
455
456
  if (validClosure) {
457
+ completion = {
458
+ answer,
459
+ intent: validDirectReply ? "direct_reply" : intent === "blocked" ? "blocked" : "complete",
460
+ };
456
461
  if (result.finalAnswerStreamed) {
457
462
  // The streaming layer already parsed and emitted the answer
458
463
  // progressively via FinalAnswerParser. Skip clearing and
@@ -610,5 +615,5 @@ async function runAgent(messages, callbacks, channel, signal, options) {
610
615
  message: "runAgent turn completed",
611
616
  meta: { done },
612
617
  });
613
- return { usage: lastUsage, outcome };
618
+ return { usage: lastUsage, outcome, completion };
614
619
  }
@@ -65,6 +65,7 @@ const thoughts_1 = require("./thoughts");
65
65
  const ouro_bot_global_installer_1 = require("./ouro-bot-global-installer");
66
66
  const launchd_1 = require("./launchd");
67
67
  const socket_client_1 = require("./socket-client");
68
+ const session_activity_1 = require("../session-activity");
68
69
  function stringField(value) {
69
70
  return typeof value === "string" ? value : null;
70
71
  }
@@ -1026,6 +1027,20 @@ function createDefaultOuroCliDeps(socketPath = socket_client_1.DEFAULT_DAEMON_SO
1026
1027
  const { main } = await Promise.resolve().then(() => __importStar(require("../../senses/cli")));
1027
1028
  await main(agentName);
1028
1029
  },
1030
+ scanSessions: async () => {
1031
+ const agentName = (0, identity_1.getAgentName)();
1032
+ const agentRoot = (0, identity_1.getAgentRoot)(agentName);
1033
+ return (0, session_activity_1.listSessionActivity)({
1034
+ sessionsDir: path.join(agentRoot, "state", "sessions"),
1035
+ friendsDir: path.join(agentRoot, "friends"),
1036
+ agentName,
1037
+ }).map((entry) => ({
1038
+ friendId: entry.friendId,
1039
+ friendName: entry.friendName,
1040
+ channel: entry.channel,
1041
+ lastActivity: entry.lastActivityAt,
1042
+ }));
1043
+ },
1029
1044
  };
1030
1045
  }
1031
1046
  function toDaemonCommand(command) {
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.decideDelegation = decideDelegation;
4
+ const runtime_1 = require("../nerves/runtime");
5
+ const CROSS_SESSION_TOOLS = new Set(["query_session", "send_message", "bridge_manage"]);
6
+ const FAST_PATH_TOOLS = new Set(["final_answer"]);
7
+ const REFLECTION_PATTERN = /\b(think|reflect|ponder|surface|surfaces|surfaced|sit with|metaboli[sz]e)\b/i;
8
+ const CROSS_SESSION_PATTERN = /\b(other chat|other session|across chats?|across sessions?|keep .* aligned|relay|carry .* across)\b/i;
9
+ function hasExplicitReflection(ingressTexts) {
10
+ return ingressTexts.some((text) => REFLECTION_PATTERN.test(text));
11
+ }
12
+ function hasCrossSessionPressure(ingressTexts, requestedToolNames) {
13
+ if (requestedToolNames.some((name) => CROSS_SESSION_TOOLS.has(name))) {
14
+ return true;
15
+ }
16
+ return ingressTexts.some((text) => CROSS_SESSION_PATTERN.test(text));
17
+ }
18
+ function hasNonFastPathToolRequest(requestedToolNames) {
19
+ return requestedToolNames.some((name) => !FAST_PATH_TOOLS.has(name));
20
+ }
21
+ function decideDelegation(input) {
22
+ const requestedToolNames = (input.requestedToolNames ?? [])
23
+ .map((name) => name.trim())
24
+ .filter((name) => name.length > 0);
25
+ const reasons = [];
26
+ if (hasExplicitReflection(input.ingressTexts)) {
27
+ reasons.push("explicit_reflection");
28
+ }
29
+ if (hasCrossSessionPressure(input.ingressTexts, requestedToolNames)) {
30
+ reasons.push("cross_session");
31
+ }
32
+ if (input.activeWork.centerOfGravity === "shared-work" || input.activeWork.bridges.some((bridge) => bridge.lifecycle === "active")) {
33
+ reasons.push("bridge_state");
34
+ }
35
+ if (input.activeWork.taskPressure.liveTaskNames.length > 0) {
36
+ reasons.push("task_state");
37
+ }
38
+ if (hasNonFastPathToolRequest(requestedToolNames)) {
39
+ reasons.push("non_fast_path_tool");
40
+ }
41
+ if (input.mustResolveBeforeHandoff || input.activeWork.mustResolveBeforeHandoff) {
42
+ reasons.push("unresolved_obligation");
43
+ }
44
+ const target = reasons.length === 0 ? "fast-path" : "delegate-inward";
45
+ const decision = {
46
+ target,
47
+ reasons,
48
+ outwardClosureRequired: target === "delegate-inward" && input.channel !== "inner",
49
+ };
50
+ (0, runtime_1.emitNervesEvent)({
51
+ component: "engine",
52
+ event: "engine.delegation_decide",
53
+ message: "computed delegation hint",
54
+ meta: {
55
+ channel: input.channel,
56
+ target: decision.target,
57
+ reasons: decision.reasons,
58
+ outwardClosureRequired: decision.outwardClosureRequired,
59
+ },
60
+ });
61
+ return decision;
62
+ }
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildProgressStory = buildProgressStory;
4
+ exports.renderProgressStory = renderProgressStory;
5
+ const runtime_1 = require("../nerves/runtime");
6
+ function labelForScope(scope) {
7
+ return scope === "inner-delegation" ? "inner work" : "shared work";
8
+ }
9
+ function compactDetail(text) {
10
+ if (typeof text !== "string")
11
+ return null;
12
+ const trimmed = text.trim();
13
+ return trimmed.length > 0 ? trimmed : null;
14
+ }
15
+ function buildProgressStory(input) {
16
+ const detailLines = [
17
+ compactDetail(input.objective),
18
+ compactDetail(input.outcomeText),
19
+ compactDetail(input.bridgeId ? `bridge: ${input.bridgeId}` : null),
20
+ compactDetail(input.taskName ? `task: ${input.taskName}` : null),
21
+ ].filter((line) => Boolean(line));
22
+ const story = {
23
+ statusLine: `${labelForScope(input.scope)}: ${input.phase}`,
24
+ detailLines,
25
+ };
26
+ (0, runtime_1.emitNervesEvent)({
27
+ component: "engine",
28
+ event: "engine.progress_story_build",
29
+ message: "built shared progress story",
30
+ meta: {
31
+ scope: input.scope,
32
+ phase: input.phase,
33
+ detailLines: detailLines.length,
34
+ hasBridge: Boolean(input.bridgeId),
35
+ hasTask: Boolean(input.taskName),
36
+ },
37
+ });
38
+ return story;
39
+ }
40
+ function renderProgressStory(story) {
41
+ return [story.statusLine, ...story.detailLines].join("\n");
42
+ }
@@ -0,0 +1,169 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.listSessionActivity = listSessionActivity;
37
+ exports.findFreshestFriendSession = findFreshestFriendSession;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const runtime_1 = require("../nerves/runtime");
41
+ const DEFAULT_ACTIVE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
42
+ function activityPriority(source) {
43
+ return source === "friend-facing" ? 0 : 1;
44
+ }
45
+ function resolveFriendName(friendId, friendsDir, agentName) {
46
+ if (friendId === "self")
47
+ return agentName;
48
+ try {
49
+ const raw = fs.readFileSync(path.join(friendsDir, `${friendId}.json`), "utf-8");
50
+ const parsed = JSON.parse(raw);
51
+ return parsed.name ?? friendId;
52
+ }
53
+ catch {
54
+ return friendId;
55
+ }
56
+ }
57
+ function parseFriendActivity(sessionPath) {
58
+ let mtimeMs;
59
+ try {
60
+ mtimeMs = fs.statSync(sessionPath).mtimeMs;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ try {
66
+ const raw = fs.readFileSync(sessionPath, "utf-8");
67
+ const parsed = JSON.parse(raw);
68
+ const explicit = parsed?.state?.lastFriendActivityAt;
69
+ if (typeof explicit === "string") {
70
+ const parsedMs = Date.parse(explicit);
71
+ if (Number.isFinite(parsedMs)) {
72
+ return {
73
+ lastActivityMs: parsedMs,
74
+ lastActivityAt: new Date(parsedMs).toISOString(),
75
+ activitySource: "friend-facing",
76
+ };
77
+ }
78
+ }
79
+ }
80
+ catch {
81
+ // fall back to file mtime below
82
+ }
83
+ return {
84
+ lastActivityMs: mtimeMs,
85
+ lastActivityAt: new Date(mtimeMs).toISOString(),
86
+ activitySource: "mtime-fallback",
87
+ };
88
+ }
89
+ function listSessionActivity(query) {
90
+ const { sessionsDir, friendsDir, agentName, activeThresholdMs = DEFAULT_ACTIVE_THRESHOLD_MS, currentSession = null, } = query;
91
+ (0, runtime_1.emitNervesEvent)({
92
+ component: "daemon",
93
+ event: "daemon.session_activity_scan",
94
+ message: "scanning session activity",
95
+ meta: {
96
+ sessionsDir,
97
+ currentSession: currentSession ? `${currentSession.friendId}/${currentSession.channel}/${currentSession.key}` : null,
98
+ },
99
+ });
100
+ if (!fs.existsSync(sessionsDir))
101
+ return [];
102
+ const now = Date.now();
103
+ const results = [];
104
+ let friendDirs;
105
+ try {
106
+ friendDirs = fs.readdirSync(sessionsDir);
107
+ }
108
+ catch {
109
+ return [];
110
+ }
111
+ for (const friendId of friendDirs) {
112
+ const friendPath = path.join(sessionsDir, friendId);
113
+ let channels;
114
+ try {
115
+ channels = fs.readdirSync(friendPath);
116
+ }
117
+ catch {
118
+ continue;
119
+ }
120
+ for (const channel of channels) {
121
+ const channelPath = path.join(friendPath, channel);
122
+ let keys;
123
+ try {
124
+ keys = fs.readdirSync(channelPath);
125
+ }
126
+ catch {
127
+ continue;
128
+ }
129
+ for (const keyFile of keys) {
130
+ if (!keyFile.endsWith(".json"))
131
+ continue;
132
+ const key = keyFile.replace(/\.json$/, "");
133
+ if (currentSession && friendId === currentSession.friendId && channel === currentSession.channel && key === currentSession.key) {
134
+ continue;
135
+ }
136
+ const sessionPath = path.join(channelPath, keyFile);
137
+ const activity = parseFriendActivity(sessionPath);
138
+ if (!activity)
139
+ continue;
140
+ if (now - activity.lastActivityMs > activeThresholdMs)
141
+ continue;
142
+ results.push({
143
+ friendId,
144
+ friendName: resolveFriendName(friendId, friendsDir, agentName),
145
+ channel,
146
+ key,
147
+ sessionPath,
148
+ lastActivityAt: activity.lastActivityAt,
149
+ lastActivityMs: activity.lastActivityMs,
150
+ activitySource: activity.activitySource,
151
+ });
152
+ }
153
+ }
154
+ }
155
+ return results.sort((a, b) => {
156
+ const sourceDiff = activityPriority(a.activitySource) - activityPriority(b.activitySource);
157
+ if (sourceDiff !== 0)
158
+ return sourceDiff;
159
+ return b.lastActivityMs - a.lastActivityMs;
160
+ });
161
+ }
162
+ function findFreshestFriendSession(query) {
163
+ const { activeOnly = false, activeThresholdMs = DEFAULT_ACTIVE_THRESHOLD_MS, ...rest } = query;
164
+ const currentSession = rest.currentSession ?? null;
165
+ const all = activeOnly
166
+ ? listSessionActivity({ ...rest, activeThresholdMs, currentSession })
167
+ : listSessionActivity({ ...rest, activeThresholdMs: Number.MAX_SAFE_INTEGER, currentSession });
168
+ return all.find((entry) => entry.friendId === query.friendId) ?? null;
169
+ }
@@ -243,8 +243,11 @@ function saveSession(filePath, messages, lastUsage, state) {
243
243
  const envelope = { version: 1, messages };
244
244
  if (lastUsage)
245
245
  envelope.lastUsage = lastUsage;
246
- if (state?.mustResolveBeforeHandoff === true) {
247
- envelope.state = { mustResolveBeforeHandoff: true };
246
+ if (state?.mustResolveBeforeHandoff === true || typeof state?.lastFriendActivityAt === "string") {
247
+ envelope.state = {
248
+ ...(state?.mustResolveBeforeHandoff === true ? { mustResolveBeforeHandoff: true } : {}),
249
+ ...(typeof state?.lastFriendActivityAt === "string" ? { lastFriendActivityAt: state.lastFriendActivityAt } : {}),
250
+ };
248
251
  }
249
252
  fs.writeFileSync(filePath, JSON.stringify(envelope, null, 2));
250
253
  }
@@ -266,10 +269,15 @@ function loadSession(filePath) {
266
269
  });
267
270
  messages = repairSessionMessages(messages);
268
271
  }
269
- const state = data?.state && typeof data.state === "object" && data.state !== null
270
- && typeof data.state.mustResolveBeforeHandoff === "boolean"
271
- && data.state.mustResolveBeforeHandoff === true
272
- ? { mustResolveBeforeHandoff: true }
272
+ const rawState = data?.state && typeof data.state === "object" && data.state !== null
273
+ ? data.state
274
+ : undefined;
275
+ const state = rawState && (rawState.mustResolveBeforeHandoff === true
276
+ || typeof rawState.lastFriendActivityAt === "string")
277
+ ? {
278
+ ...(rawState.mustResolveBeforeHandoff === true ? { mustResolveBeforeHandoff: true } : {}),
279
+ ...(typeof rawState.lastFriendActivityAt === "string" ? { lastFriendActivityAt: rawState.lastFriendActivityAt } : {}),
280
+ }
273
281
  : undefined;
274
282
  return { messages, lastUsage: data.lastUsage, state };
275
283
  }