@brantrusnak/openclaw-omadeus 1.0.0 → 1.0.2

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.
@@ -0,0 +1,250 @@
1
+ import {
2
+ type OmadeusChannelConfig,
3
+ type OmadeusInboundMessage,
4
+ type OmadeusInboundPolicy,
5
+ OMADEUS_INBOUND_ENTITY_KIND_SET,
6
+ type OmadeusSubscribableKind,
7
+ } from "./types.js";
8
+
9
+ /** Default inbound policy when `channels.omadeus.inbound` is absent. */
10
+ export const DEFAULT_INBOUND_POLICY: Required<
11
+ Pick<OmadeusInboundPolicy, "direct" | "channels" | "entities">
12
+ > & { version: number } = {
13
+ version: 1,
14
+ direct: { enabled: true, requireMention: "never" },
15
+ channels: { enabled: false, requireMention: "outsideAllowlist" },
16
+ entities: { enabled: false, requireMention: "always" },
17
+ };
18
+
19
+ export type InboundPolicyDecision =
20
+ | { allow: true }
21
+ | { allow: false; reason: string; details?: Record<string, unknown> };
22
+
23
+ function mergePolicy(cfg: OmadeusChannelConfig | undefined) {
24
+ const inbound = cfg?.inbound;
25
+ const version = typeof inbound?.version === "number" && inbound.version >= 1 ? inbound.version : 1;
26
+ const direct = {
27
+ ...DEFAULT_INBOUND_POLICY.direct,
28
+ ...inbound?.direct,
29
+ requireMention: inbound?.direct?.requireMention ?? DEFAULT_INBOUND_POLICY.direct.requireMention,
30
+ };
31
+ const channels = {
32
+ ...DEFAULT_INBOUND_POLICY.channels,
33
+ ...inbound?.channels,
34
+ requireMention:
35
+ inbound?.channels?.requireMention ?? DEFAULT_INBOUND_POLICY.channels.requireMention,
36
+ };
37
+ const entities = {
38
+ ...DEFAULT_INBOUND_POLICY.entities,
39
+ ...inbound?.entities,
40
+ requireMention:
41
+ inbound?.entities?.requireMention ?? DEFAULT_INBOUND_POLICY.entities.requireMention,
42
+ };
43
+ return { version, direct, channels, entities };
44
+ }
45
+
46
+ function surfaceForKind(kind: OmadeusSubscribableKind): "direct" | "channel" | "entity" {
47
+ if (kind === "direct") return "direct";
48
+ if (kind === "channel") return "channel";
49
+ return "entity";
50
+ }
51
+
52
+ function senderAllowed(allowed: number[] | undefined, fromReferenceId: number): boolean {
53
+ if (!allowed || allowed.length === 0) return true;
54
+ return allowed.includes(fromReferenceId);
55
+ }
56
+
57
+ function channelGeoAllowed(params: {
58
+ roomId: number;
59
+ channelViewId?: number;
60
+ allowedRoomIds?: number[];
61
+ allowedChannelViewIds?: number[];
62
+ }): { geoInAllowlist: boolean; details: Record<string, unknown> } {
63
+ const { roomId, channelViewId, allowedRoomIds = [], allowedChannelViewIds = [] } = params;
64
+ const hasRooms = allowedRoomIds.length > 0;
65
+ const hasViews = allowedChannelViewIds.length > 0;
66
+ let geoInAllowlist = true;
67
+ if (hasRooms && hasViews) {
68
+ geoInAllowlist =
69
+ allowedRoomIds.includes(roomId) ||
70
+ (channelViewId !== undefined && allowedChannelViewIds.includes(channelViewId));
71
+ } else if (hasRooms) {
72
+ geoInAllowlist = allowedRoomIds.includes(roomId);
73
+ } else if (hasViews) {
74
+ geoInAllowlist = channelViewId !== undefined && allowedChannelViewIds.includes(channelViewId);
75
+ }
76
+ return {
77
+ geoInAllowlist,
78
+ details: {
79
+ roomId,
80
+ channelViewId,
81
+ allowedRoomIds,
82
+ allowedChannelViewIds,
83
+ hasRooms,
84
+ hasViews,
85
+ geoInAllowlist,
86
+ },
87
+ };
88
+ }
89
+
90
+ function entityKindAllowed(kind: OmadeusSubscribableKind, allowedKinds?: string[]): boolean {
91
+ if (!allowedKinds || allowedKinds.length === 0) {
92
+ return OMADEUS_INBOUND_ENTITY_KIND_SET.has(String(kind));
93
+ }
94
+ return allowedKinds.includes(String(kind));
95
+ }
96
+
97
+ function entityRoomOk(roomId: number, allowedRoomIds?: number[]): boolean {
98
+ if (!allowedRoomIds || allowedRoomIds.length === 0) return true;
99
+ return allowedRoomIds.includes(roomId);
100
+ }
101
+
102
+ function mentionRequired(params: {
103
+ requireMention?: "never" | "always" | "outsideAllowlist";
104
+ inAllowlist: boolean;
105
+ isMention: boolean;
106
+ }): boolean {
107
+ const requireMention = params.requireMention ?? "never";
108
+ const { inAllowlist, isMention } = params;
109
+ if (requireMention === "never") return false;
110
+ if (requireMention === "always") return !isMention;
111
+ // outsideAllowlist
112
+ if (inAllowlist) return false;
113
+ return !isMention;
114
+ }
115
+
116
+ /**
117
+ * Evaluate whether a normalized Jaguar inbound should be dispatched to OpenClaw.
118
+ * Callers must drop self-authored messages separately if they prefer logging there.
119
+ */
120
+ export function evaluateOmadeusInboundPolicy(params: {
121
+ inbound: OmadeusInboundMessage;
122
+ omadeusCfg: OmadeusChannelConfig | undefined;
123
+ selfReferenceId: number;
124
+ }): InboundPolicyDecision {
125
+ const { inbound, omadeusCfg, selfReferenceId } = params;
126
+
127
+ if (inbound.fromReferenceId === selfReferenceId) {
128
+ return {
129
+ allow: false,
130
+ reason: "self_message",
131
+ details: { fromReferenceId: inbound.fromReferenceId, selfReferenceId },
132
+ };
133
+ }
134
+
135
+ const policy = mergePolicy(omadeusCfg);
136
+ const surface = surfaceForKind(inbound.subscribableKind);
137
+
138
+ if (surface === "direct") {
139
+ if (!policy.direct.enabled) {
140
+ return { allow: false, reason: "direct_disabled", details: { surface } };
141
+ }
142
+ if (!senderAllowed(policy.direct.allowedSenderReferenceIds, inbound.fromReferenceId)) {
143
+ return {
144
+ allow: false,
145
+ reason: "direct_sender_not_allowed",
146
+ details: { fromReferenceId: inbound.fromReferenceId },
147
+ };
148
+ }
149
+ const req = policy.direct.requireMention ?? "never";
150
+ if (mentionRequired({ requireMention: req, inAllowlist: true, isMention: inbound.isMention })) {
151
+ return { allow: false, reason: "direct_mention_required", details: { requireMention: req } };
152
+ }
153
+ return { allow: true };
154
+ }
155
+
156
+ if (surface === "channel") {
157
+ if (!policy.channels.enabled) {
158
+ return { allow: false, reason: "channels_disabled", details: { surface } };
159
+ }
160
+ if (!senderAllowed(policy.channels.allowedSenderReferenceIds, inbound.fromReferenceId)) {
161
+ return {
162
+ allow: false,
163
+ reason: "channel_sender_not_allowed",
164
+ details: { fromReferenceId: inbound.fromReferenceId },
165
+ };
166
+ }
167
+ const rv = channelGeoAllowed({
168
+ roomId: inbound.roomId,
169
+ channelViewId: inbound.channelViewId,
170
+ allowedRoomIds: policy.channels.allowedRoomIds,
171
+ allowedChannelViewIds: policy.channels.allowedChannelViewIds,
172
+ });
173
+ const senderInList =
174
+ !policy.channels.allowedSenderReferenceIds ||
175
+ policy.channels.allowedSenderReferenceIds.length === 0 ||
176
+ policy.channels.allowedSenderReferenceIds.includes(inbound.fromReferenceId);
177
+ const inAllowlist = rv.geoInAllowlist && senderInList;
178
+ const channelMention =
179
+ policy.channels.requireMention ?? DEFAULT_INBOUND_POLICY.channels.requireMention;
180
+ if (
181
+ mentionRequired({
182
+ requireMention: channelMention,
183
+ inAllowlist,
184
+ isMention: inbound.isMention,
185
+ })
186
+ ) {
187
+ return {
188
+ allow: false,
189
+ reason: "channel_mention_required",
190
+ details: {
191
+ requireMention: channelMention,
192
+ inAllowlist,
193
+ isMention: inbound.isMention,
194
+ },
195
+ };
196
+ }
197
+ return { allow: true };
198
+ }
199
+
200
+ // entity
201
+ if (!policy.entities.enabled) {
202
+ return { allow: false, reason: "entities_disabled", details: { kind: inbound.subscribableKind } };
203
+ }
204
+ if (!entityKindAllowed(inbound.subscribableKind, policy.entities.allowedKinds)) {
205
+ return {
206
+ allow: false,
207
+ reason: "entity_kind_not_allowed",
208
+ details: { kind: inbound.subscribableKind, allowedKinds: policy.entities.allowedKinds },
209
+ };
210
+ }
211
+ if (!senderAllowed(policy.entities.allowedSenderReferenceIds, inbound.fromReferenceId)) {
212
+ return {
213
+ allow: false,
214
+ reason: "entity_sender_not_allowed",
215
+ details: { fromReferenceId: inbound.fromReferenceId },
216
+ };
217
+ }
218
+ if (!entityRoomOk(inbound.roomId, policy.entities.allowedRoomIds)) {
219
+ return {
220
+ allow: false,
221
+ reason: "entity_room_not_allowed",
222
+ details: { roomId: inbound.roomId, allowedRoomIds: policy.entities.allowedRoomIds },
223
+ };
224
+ }
225
+
226
+ const roomList = policy.entities.allowedRoomIds ?? [];
227
+ const inAllowlist =
228
+ roomList.length === 0 || roomList.includes(inbound.roomId);
229
+ const entityMention =
230
+ policy.entities.requireMention ?? DEFAULT_INBOUND_POLICY.entities.requireMention;
231
+ if (
232
+ mentionRequired({
233
+ requireMention: entityMention,
234
+ inAllowlist,
235
+ isMention: inbound.isMention,
236
+ })
237
+ ) {
238
+ return {
239
+ allow: false,
240
+ reason: "entity_mention_required",
241
+ details: {
242
+ requireMention: entityMention,
243
+ inAllowlist,
244
+ isMention: inbound.isMention,
245
+ },
246
+ };
247
+ }
248
+
249
+ return { allow: true };
250
+ }
package/src/inbound.ts CHANGED
@@ -56,6 +56,23 @@ function stripLeadingMention(body: string): string {
56
56
  return body.replace(/^\*\*@[^*]+\*\*\s*/, "").trim();
57
57
  }
58
58
 
59
+ function readChannelViewId(metadata: unknown): number | undefined {
60
+ if (metadata === null || metadata === undefined || typeof metadata !== "object") {
61
+ return undefined;
62
+ }
63
+ const m = metadata as Record<string, unknown>;
64
+ for (const key of ["channelViewId", "channel_view_id", "viewId", "subscribableViewId"]) {
65
+ const v = m[key];
66
+ if (typeof v === "number" && Number.isFinite(v)) {
67
+ return v;
68
+ }
69
+ if (typeof v === "string" && /^\d+$/.test(v.trim())) {
70
+ return Number(v.trim());
71
+ }
72
+ }
73
+ return undefined;
74
+ }
75
+
59
76
  /**
60
77
  * Determine whether a raw Jaguar socket payload is an OmadeusMessage.
61
78
  */
@@ -95,6 +112,8 @@ export function parseJaguarMessage(
95
112
 
96
113
  if (!content) return null;
97
114
 
115
+ const channelViewId = readChannelViewId(msg.metadata);
116
+
98
117
  return {
99
118
  messageId: msg.id,
100
119
  from: String(msg.senderReferenceId),
@@ -104,6 +123,7 @@ export function parseJaguarMessage(
104
123
  roomName: msg.roomName,
105
124
  subscribableType: msg.subscribableType,
106
125
  subscribableKind: msg.subscribableKind,
126
+ ...(channelViewId !== undefined ? { channelViewId } : {}),
107
127
  isMention: mentioned,
108
128
  timestamp: msg.createdAtTimestamp
109
129
  ? Math.floor(msg.createdAtTimestamp * 1000)
@@ -0,0 +1,84 @@
1
+ import { listOrganizationMembers } from "./api/auth.api.js";
2
+ import type { OmadeusOrganizationMember } from "./types.js";
3
+ import type { OmadeusApiOptions } from "./utils/http.util.js";
4
+
5
+ function formatMemberLabel(m: OmadeusOrganizationMember): string {
6
+ const fullName = `${m.firstName ?? ""} ${m.lastName ?? ""}`.trim();
7
+ if (fullName) {
8
+ return fullName;
9
+ }
10
+ if (m.title?.trim()) {
11
+ return m.title.trim();
12
+ }
13
+ if (m.email?.trim()) {
14
+ return m.email.trim();
15
+ }
16
+ return `Member ${m.referenceId}`;
17
+ }
18
+
19
+ /**
20
+ * Resolves Omadeus `referenceId` → human-readable label for the current organization (session JWT).
21
+ * Used so agents do not echo raw ids like 210 to users.
22
+ */
23
+ export async function buildReferenceIdNameMap(
24
+ apiOpts: OmadeusApiOptions,
25
+ ): Promise<Map<number, string>> {
26
+ const { organizationId } = apiOpts.tokenManager.getPayload();
27
+ const sessionToken = apiOpts.tokenManager.getToken();
28
+ const members = await listOrganizationMembers({
29
+ maestroUrl: apiOpts.maestroUrl,
30
+ sessionToken,
31
+ organizationId,
32
+ });
33
+ const map = new Map<number, string>();
34
+ for (const m of members) {
35
+ map.set(m.referenceId, formatMemberLabel(m));
36
+ }
37
+ return map;
38
+ }
39
+
40
+ /**
41
+ * Adds a `people` object mapping each `*ReferenceId` field in the nugget row to a display name.
42
+ * Keys match the source field names (e.g. `memberReferenceId: "Pat Example"`).
43
+ */
44
+ export async function mergePeopleIntoNuggetAgentPayload(
45
+ apiOpts: OmadeusApiOptions,
46
+ fullRecord: Record<string, unknown>,
47
+ basePayload: Record<string, unknown>,
48
+ ): Promise<Record<string, unknown>> {
49
+ let nameByRef: Map<number, string>;
50
+ try {
51
+ nameByRef = await buildReferenceIdNameMap(apiOpts);
52
+ } catch {
53
+ return { ...basePayload };
54
+ }
55
+ const people: Record<string, string> = {};
56
+ for (const [key, v] of Object.entries(fullRecord)) {
57
+ if (!/referenceid$/i.test(key)) {
58
+ continue;
59
+ }
60
+ const id =
61
+ typeof v === "number" && Number.isFinite(v)
62
+ ? v
63
+ : typeof v === "string" && /^\d+$/.test(v.trim())
64
+ ? Number(v.trim())
65
+ : NaN;
66
+ if (!Number.isFinite(id)) {
67
+ continue;
68
+ }
69
+ const name = nameByRef.get(id);
70
+ if (name) {
71
+ people[key] = name;
72
+ }
73
+ }
74
+ const out: Record<string, unknown> = { ...basePayload };
75
+ if (Object.keys(people).length > 0) {
76
+ out.people = people;
77
+ for (const key of Object.keys(people)) {
78
+ if (key in out) {
79
+ delete out[key];
80
+ }
81
+ }
82
+ }
83
+ return out;
84
+ }
@@ -5,8 +5,9 @@ import {
5
5
  type OpenClawConfig,
6
6
  type RuntimeEnv,
7
7
  } from "../runtime-api.js";
8
- import { createNugget, resolveTaskChannelRoomId, searchNuggetByNumber } from "./api/nugget.api.js";
8
+ import { createNugget, findNuggetByTaskChannelRoom, resolveTaskChannelRoomId, searchNuggetByNumber } from "./api/nugget.api.js";
9
9
  import {
10
+ appendNuggetContextForTaskOrNuggetRoom,
10
11
  appendNuggetLookupContextForAgent,
11
12
  parseChannelTaskCreateIntent,
12
13
  parseNuggetLookupIntent,
@@ -15,8 +16,12 @@ import {
15
16
  import type { OutboundDeps } from "./outbound.js";
16
17
  import { createOmadeusReplyDispatcher } from "./reply-dispatcher.js";
17
18
  import { getOmadeusChannelConfig } from "./config.js";
19
+ import { evaluateOmadeusInboundPolicy } from "./inbound-policy.js";
18
20
  import { getOmadeusRuntime } from "./runtime.js";
19
- import type { OmadeusInboundMessage } from "./types.js";
21
+ import {
22
+ type OmadeusInboundMessage,
23
+ OMADEUS_INBOUND_ENTITY_KIND_SET,
24
+ } from "./types.js";
20
25
 
21
26
  type Log = {
22
27
  info: (msg: string, extra?: Record<string, unknown>) => void;
@@ -25,15 +30,50 @@ type Log = {
25
30
  debug?: (msg: string, extra?: Record<string, unknown>) => void;
26
31
  };
27
32
 
33
+ const SK = "`subscribableKind`";
34
+
35
+ /** Injected into BodyForAgent so the model uses Jaguar room kind, not invented OpenClaw `task/...` keys. */
36
+ function buildOmadeusEntityRoomContextLine(kind: string): string {
37
+ if (kind === "task") {
38
+ return `This chat is an **Omadeus Task** room (${SK} \`task\`). If the user asks about the Task, its status, or says \"the task\", they mean **this** Omadeus Task / this thread — not an OpenClaw \"task\" or a \"session id\" for it.`;
39
+ }
40
+ if (kind === "nugget") {
41
+ return `This chat is an **Omadeus Nugget** room (${SK} \`nugget\`). If the user asks about the Nugget, its status, or colloquially says \"the task\", they mean **this** Omadeus Nugget / this thread — not an OpenClaw \"task\" or a \"session id\" for it. (Task and Nugget are different; infer from this room's ${SK}.)`;
42
+ }
43
+ const other: Record<string, string> = {
44
+ project: "Project",
45
+ release: "Release",
46
+ sprint: "Sprint",
47
+ summary: "Summary",
48
+ client: "Client",
49
+ folder: "Folder",
50
+ };
51
+ const label = other[kind] ?? kind;
52
+ return `This chat is an **Omadeus ${label}** room (${SK} \`${kind}\`). User questions refer to that Omadeus ${label} in this thread — not an OpenClaw \"session id\" for it.`;
53
+ }
54
+
55
+ function shouldSkipTaskRoomNuggetFetch(rawBody: string): boolean {
56
+ const t = rawBody.trim();
57
+ if (!t) {
58
+ return true;
59
+ }
60
+ if (/^(ok|k|thanks?|thank you|ty|sounds good|got it|yes|yep|no|nope)\s*!*\.?$/i.test(t)) {
61
+ return true;
62
+ }
63
+ return false;
64
+ }
65
+
28
66
  export type OmadeusMessageHandlerDeps = {
29
67
  cfg: OpenClawConfig;
30
68
  runtime: RuntimeEnv;
31
69
  log: Log;
32
70
  outboundDeps: OutboundDeps;
71
+ /** Authenticated Omadeus user; used to drop self-authored messages and inbound policy. */
72
+ selfReferenceId: number;
33
73
  };
34
74
 
35
75
  export function createOmadeusMessageHandler(deps: OmadeusMessageHandlerDeps) {
36
- const { cfg, runtime, log, outboundDeps } = deps;
76
+ const { cfg, runtime, log, outboundDeps, selfReferenceId } = deps;
37
77
  const core = getOmadeusRuntime();
38
78
  const omadeusCfg = getOmadeusChannelConfig(cfg);
39
79
 
@@ -54,55 +94,26 @@ export function createOmadeusMessageHandler(deps: OmadeusMessageHandlerDeps) {
54
94
  return;
55
95
  }
56
96
 
57
- const selectedPublicRoomId = omadeusCfg?.selectedChannelPublicRoomId;
58
- const selectedPrivateRoomId = omadeusCfg?.selectedChannelPrivateRoomId;
59
- const selectedMemberReferenceId = omadeusCfg?.selectedMemberReferenceId;
60
- const hasSelectedScope =
61
- typeof selectedPublicRoomId === "number" || typeof selectedPrivateRoomId === "number";
62
- let inSelectedChannelRoom = false;
63
- let isSelectedMemberTaskPrivate = false;
64
- if (hasSelectedScope) {
65
- inSelectedChannelRoom =
66
- inbound.roomId === selectedPublicRoomId || inbound.roomId === selectedPrivateRoomId;
67
- const isSelectedMember =
68
- typeof selectedMemberReferenceId !== "number" ||
69
- inbound.fromReferenceId === selectedMemberReferenceId;
70
- isSelectedMemberTaskPrivate =
71
- inbound.subscribableKind === "task" && inbound.isMention && isSelectedMember;
72
- const allowSelectedChannelMessage = inSelectedChannelRoom && isSelectedMember;
73
- if (!allowSelectedChannelMessage && !isSelectedMemberTaskPrivate) {
74
- log.info("omadeus: dropped message outside selected channel scope", {
75
- roomId: inbound.roomId,
76
- roomName: inbound.roomName,
77
- selectedPublicRoomId,
78
- selectedPrivateRoomId,
79
- selectedMemberReferenceId,
80
- kind: inbound.subscribableKind,
81
- fromReferenceId: inbound.fromReferenceId,
82
- isMention: inbound.isMention,
83
- selectedMemberMatched: isSelectedMember,
84
- });
85
- return;
86
- }
87
- }
88
-
89
- const useAccessGroups =
90
- (cfg.commands as Record<string, unknown> | undefined)?.useAccessGroups !== false;
91
-
92
- // For group messages, only respond when mentioned (unless groupPolicy is open)
93
- const bypassMentionGate = hasSelectedScope && (inSelectedChannelRoom || isSelectedMemberTaskPrivate);
94
- if (!isDirectMessage && !inbound.isMention && !bypassMentionGate) {
95
- log.debug?.("skipping group message (not mentioned)");
96
- return;
97
- }
98
- if (!isDirectMessage && !inbound.isMention && bypassMentionGate) {
99
- log.info("omadeus: processing selected-scope group message without mention", {
97
+ const policyDecision = evaluateOmadeusInboundPolicy({
98
+ inbound,
99
+ omadeusCfg,
100
+ selfReferenceId,
101
+ });
102
+ if (!policyDecision.allow) {
103
+ log.info("omadeus: dropped message by inbound policy", {
104
+ reason: policyDecision.reason,
105
+ ...(policyDecision.details ?? {}),
100
106
  roomId: inbound.roomId,
101
- roomName: inbound.roomName,
102
107
  kind: inbound.subscribableKind,
108
+ fromReferenceId: inbound.fromReferenceId,
109
+ isMention: inbound.isMention,
103
110
  });
111
+ return;
104
112
  }
105
113
 
114
+ const useAccessGroups =
115
+ (cfg.commands as Record<string, unknown> | undefined)?.useAccessGroups !== false;
116
+
106
117
  const hasControlCommand = core.channel.text.hasControlCommand(rawBody, cfg);
107
118
  const commandGate = resolveControlCommandGate({
108
119
  useAccessGroups,
@@ -125,10 +136,7 @@ export function createOmadeusMessageHandler(deps: OmadeusMessageHandlerDeps) {
125
136
  const createIntent = parseChannelTaskCreateIntent(rawBody);
126
137
  if (createIntent) {
127
138
  try {
128
- const memberReferenceId =
129
- typeof selectedMemberReferenceId === "number"
130
- ? selectedMemberReferenceId
131
- : inbound.fromReferenceId;
139
+ const memberReferenceId = inbound.fromReferenceId;
132
140
  const created = await createNugget(outboundDeps.apiOpts, {
133
141
  title: createIntent.title,
134
142
  description: createIntent.description,
@@ -181,18 +189,49 @@ export function createOmadeusMessageHandler(deps: OmadeusMessageHandlerDeps) {
181
189
  const nugget = await searchNuggetByNumber(outboundDeps.apiOpts, {
182
190
  nuggetNumber: nuggetIntent.nuggetNumber,
183
191
  });
184
- bodyForAgent = appendNuggetLookupContextForAgent(
192
+ bodyForAgent = await appendNuggetLookupContextForAgent(
185
193
  rawBody,
186
194
  nuggetIntent.nuggetNumber,
187
195
  nugget,
196
+ outboundDeps.apiOpts,
188
197
  );
189
198
  } catch (err) {
190
199
  const errorMessage = err instanceof Error ? err.message : String(err);
191
200
  runtime.error?.(`omadeus nugget lookup failed: ${errorMessage}`);
192
- bodyForAgent = appendNuggetLookupContextForAgent(
201
+ bodyForAgent = await appendNuggetLookupContextForAgent(
193
202
  rawBody,
194
203
  nuggetIntent.nuggetNumber,
195
204
  null,
205
+ outboundDeps.apiOpts,
206
+ errorMessage,
207
+ );
208
+ }
209
+ }
210
+
211
+ const isTaskOrNuggetRoom =
212
+ !isDirectMessage && (inbound.subscribableKind === "task" || inbound.subscribableKind === "nugget");
213
+ if (isTaskOrNuggetRoom && !nuggetIntent && !createIntent && !shouldSkipTaskRoomNuggetFetch(rawBody)) {
214
+ try {
215
+ const nugget = await findNuggetByTaskChannelRoom(outboundDeps.apiOpts, {
216
+ roomId: inbound.roomId,
217
+ roomName: inbound.roomName,
218
+ });
219
+ bodyForAgent = await appendNuggetContextForTaskOrNuggetRoom(
220
+ bodyForAgent,
221
+ inbound.roomId,
222
+ inbound.roomName,
223
+ nugget,
224
+ outboundDeps.apiOpts,
225
+ );
226
+ } catch (err) {
227
+ const errorMessage = err instanceof Error ? err.message : String(err);
228
+ runtime.error?.(`omadeus task room nugget lookup failed: ${errorMessage}`);
229
+ bodyForAgent = await appendNuggetContextForTaskOrNuggetRoom(
230
+ bodyForAgent,
231
+ inbound.roomId,
232
+ inbound.roomName,
233
+ null,
234
+ outboundDeps.apiOpts,
196
235
  errorMessage,
197
236
  );
198
237
  }
@@ -210,6 +249,13 @@ export function createOmadeusMessageHandler(deps: OmadeusMessageHandlerDeps) {
210
249
  },
211
250
  });
212
251
 
252
+ // Omadeus entity rooms (Jaguar subscribableKind): see OmadeusInboundEntityKind,
253
+ // OMADEUS_INBOUND_ENTITY_KINDS. Models conflate "task" with OpenClaw and invent `task/<title>` keys.
254
+ if (!isDirectMessage && OMADEUS_INBOUND_ENTITY_KIND_SET.has(String(inbound.subscribableKind))) {
255
+ const entityLine = buildOmadeusEntityRoomContextLine(String(inbound.subscribableKind));
256
+ bodyForAgent = `${bodyForAgent}\n\n[OpenClaw] ${entityLine} \`session_status\` is only for **OpenClaw** gateway session state (model/usage, etc.); for that, use this key or \`current\`: ${route.sessionKey} — never \`task/\` + a title as a session key.`;
257
+ }
258
+
213
259
  const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
214
260
  const inboundLabel = isDirectMessage
215
261
  ? `Omadeus DM from ${senderName}`