oomi-ai 0.7.0 → 0.7.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.
@@ -271,12 +271,15 @@ To reach the user on their phone when they are not in the conversation, send a p
271
271
  oomi notify "Your 7-day plan is ready. Want to look now?" --title "Fitness" --json
272
272
  ```
273
273
 
274
+ Add `--connection NAME` when you run it outside an Oomi conversation (for example from a scheduled job). Turns Oomi starts already set `OOMI_CONNECTION`. Omit it for the default OpenClaw connection.
275
+
274
276
  Rules:
275
277
 
276
278
  - Notify only for things worth interrupting the user for: a reminder they asked for, a finished task, or something time-sensitive. Say everything else in chat.
277
279
  - Keep the message short and specific. The title defaults to your agent name.
278
280
  - Tapping the notification opens the Oomi app to the conversation, where the message also appears.
279
- - Messages you send the user on the Oomi channel already notify their phone. Do not also run `oomi notify` for the same message.
281
+ - Replies to the user's own Oomi messages, and OpenClaw sends on the Oomi channel, already notify their phone. Do not also run `oomi notify` for the same message.
282
+ - Anything you start on your own, such as a reminder or finished background work, needs `oomi notify`. Hermes agents have no other way to reach the phone.
280
283
  - If `notification.status` is `skipped`, tell the user `notification.guidance` instead of retrying.
281
284
  - If the request is rate limited, do not retry until `retryAfterSeconds` has passed.
282
285
 
@@ -338,3 +341,9 @@ Use `oomi routines pause|resume|end <routine-id> --file action.json` with expect
338
341
  For instructions in another chat, the user must first grant this connection routineManagementEnabled through Oomi Agent settings or `routines settings` using an authenticated Oomi sourceMessageId. Then routine/response mutations can substitute externalInstruction:{channel,messageId,instruction} for sourceMessageId. Record the actual message reference and instruction. Oomi labels this runtime-attested, not independently authenticated. Never turn on the permission yourself or invent external approval. Ownership, data permissions, and primary selection remain separate.
339
342
 
340
343
  The running bridge receives routine.approved, routine.due, routine.changed, routine.response_recorded, and primary_agent.changed. Ordinary events sync quietly without a model call; explicitly enabled routine wakes are bounded and are not retried after an uncertain model dispatch. Refresh canonical state before acting on a delayed event. Due events already have notificationHandled:true; do not send another phone alert or message for every event. Persist useful context for the next conversation. If a follow-up is explicitly linked to an event, include relatedEventId in the notification API so Oomi suppresses an extra push. Keep Oomi credentials private; all /v1/routines, /v1/agent_preferences, /v1/routine_occurrences, and /v1/agent_events requests use the saved device bearer token, never a user ID supplied by the agent.
344
+
345
+ ### Reply first, then share routine cards
346
+
347
+ Always answer the person in your own conversational voice after a routine or Space tool succeeds. Explain what you recommend or what changed, and invite a useful next step when appropriate. A tool result or review card is not the reply. Do not return only routine names, IDs, JSON, or a stock list of chips. Mention the routines that actually belong to this request; do not automatically recreate or list three routines on unrelated requests. Oomi places newly created routine cards beneath the completed reply in that creation turn. Later approvals update the original card in place. Keep talking normally after a user starts or changes a routine; do not require another UI click when their instruction is already clear.
348
+
349
+ Oomi renders simple Markdown in chat: short paragraphs, bullet or numbered lists, and **bold**. Keep replies brief and scannable, and put long plans in a Space note instead of the chat. A Space link in your reply appears as a card, so share it once rather than repeating the URL or listing IDs.
package/bin/oomi-ai.js CHANGED
@@ -286,9 +286,10 @@ Commands:
286
286
  Save user-requested routines and settings. Use the current sourceMessageId,
287
287
  expectedRevision, and a stable idempotencyKey. No extra approval button is needed.
288
288
 
289
- notify "<message>" [--title TITLE]
289
+ notify "<message>" [--title TITLE] [--connection NAME]
290
290
  Send the linked user a push notification on their phone. Use it for things worth
291
- interrupting them for; say everything else in chat. Limited per hour.
291
+ interrupting them for; say everything else in chat. Limited per hour. Turns started
292
+ by an Oomi bridge already use their own connection (OOMI_CONNECTION).
292
293
 
293
294
  Common flags:
294
295
  --agents-file PATH Override AGENTS.md path
@@ -2641,7 +2642,7 @@ async function startOpenclawBridge(flags) {
2641
2642
  }
2642
2643
 
2643
2644
  const localRuntime = bridgeState.localAgent
2644
- ? new LocalAgentRuntime(bridgeState.localAgent, path.dirname(resolveBridgeStatePath()), (health) => updateBridgeStatus(health))
2645
+ ? new LocalAgentRuntime({ ...bridgeState.localAgent, connection: bridgeConnectionName }, path.dirname(resolveBridgeStatePath()), (health) => updateBridgeStatus(health))
2645
2646
  : null;
2646
2647
  const gateway = localRuntime ? { gatewayUrl: 'local-agent:', configPath: resolveBridgeStatePath() } : readOpenclawGatewayConfig();
2647
2648
  if (!localRuntime && !gateway.token && !gateway.password) {
@@ -4546,7 +4547,8 @@ async function main() {
4546
4547
  process.exit(0);
4547
4548
  }
4548
4549
 
4549
- const connection = args.flags.connection || args.flags.name || (command === 'connect' && args.flags.runtime !== 'openclaw' ? args.flags.runtime : '');
4550
+ // Agents started by an Oomi bridge inherit OOMI_CONNECTION, so their commands reach their own account.
4551
+ const connection = args.flags.connection || args.flags.name || (command === 'connect' ? (args.flags.runtime !== 'openclaw' ? args.flags.runtime : '') : String(process.env.OOMI_CONNECTION || '').trim());
4550
4552
  if (connection && !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(connection)) throw new Error('Connection name must use lowercase letters, numbers, underscores, or hyphens.');
4551
4553
  bridgeConnectionName = connection || '';
4552
4554
 
@@ -4556,6 +4558,7 @@ async function main() {
4556
4558
  spaces: { catalog: 'oomi spaces catalog', list: 'oomi spaces list', create: 'oomi spaces create --file space.json', update: 'oomi spaces update ID --file change.json', revisions: 'oomi spaces revisions ID', guide: 'oomi instructions' },
4557
4559
  health: { steps: 'oomi context health --window week --metric steps --json', calendarWeek: 'oomi context health --window range --from YYYY-MM-DD --to YYYY-MM-DD --metric steps --json', largeResults: '--output new-file.json', plugins: 'oomi context plugins', missing: 'unknown, never zero', week: 'rolling seven days; use explicit range for calendar week' },
4558
4560
  routines: 'oomi routines list; oomi routines context', identity: 'oomi identity get', updates: 'oomi updates',
4561
+ notify: { send: 'oomi notify "<message>" --title TITLE --json', when: 'reminders the user asked for, finished work, time-sensitive news; everything else in chat', skipped: 'tell the user notification.guidance', rateLimited: 'wait retryAfterSeconds' },
4559
4562
  costs: { passiveEvents: 'no model turn', wakeAgent: 'routine.agentWakeEnabled defaults false; enable only on explicit user request', maximumRoutineWakeAttemptsPerHour: 6, retries: 'uncertain event turns are not automatically rerun', connectionCheck: 'first connect or explicit oomi verify makes one model call; reconnect reuses verification', identity: 'a changed identity revision is delivered once to keep names consistent' }
4560
4563
  }, null, 2)); return;
4561
4564
  }
package/lib/localAgent.js CHANGED
@@ -5,6 +5,56 @@ import { spawn } from 'node:child_process';
5
5
  import { EventEmitter } from 'node:events';
6
6
  import { createHash, randomUUID } from 'node:crypto';
7
7
 
8
+ const IMAGE_EXTENSIONS = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' };
9
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
10
+
11
+ /**
12
+ * Chat photos arrive inline as base64 (the shape OpenClaw's gateway also accepts), with an
13
+ * optional `storagePath` in the owner's Oomi storage so the conversation can show it again
14
+ * later. Hermes looks at one image per query.
15
+ */
16
+ export function readImageAttachments(attachments) {
17
+ if (!Array.isArray(attachments) || attachments.length === 0) return [];
18
+ if (attachments.length > 1) throw new Error('Send one photo at a time.');
19
+ return attachments.map((attachment) => {
20
+ const mimeType = String(attachment?.mimeType || '').toLowerCase();
21
+ if (attachment?.type !== 'image' || !IMAGE_EXTENSIONS[mimeType]) throw new Error('Only JPEG, PNG, WebP, or GIF photos can be sent.');
22
+ const bytes = Buffer.from(typeof attachment.content === 'string' ? attachment.content : '', 'base64');
23
+ if (!bytes.length) throw new Error('The photo arrived empty. Try sending it again.');
24
+ if (bytes.length > MAX_IMAGE_BYTES) throw new Error('That photo is too large. Photos can be up to 10 MB.');
25
+ const dimension = (value) => (Number.isFinite(value) && value > 0 ? Math.round(value) : undefined);
26
+ return {
27
+ mimeType,
28
+ bytes,
29
+ storagePath: typeof attachment.storagePath === 'string' && attachment.storagePath.trim() ? attachment.storagePath.trim() : undefined,
30
+ width: dimension(attachment.width),
31
+ height: dimension(attachment.height),
32
+ };
33
+ });
34
+ }
35
+
36
+ // Private files for one turn; the caller removes the folder when the turn ends.
37
+ function writeTurnImages(images) {
38
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'oomi-turn-'));
39
+ fs.chmodSync(directory, 0o700);
40
+ const paths = images.map((image, index) => {
41
+ const file = path.join(directory, `photo-${index + 1}${IMAGE_EXTENSIONS[image.mimeType]}`);
42
+ fs.writeFileSync(file, image.bytes, { mode: 0o600 });
43
+ return file;
44
+ });
45
+ return { directory, paths };
46
+ }
47
+
48
+ // What the conversation remembers: the words, plus where the owner's photo is stored (never the bytes).
49
+ function userContent(text, images) {
50
+ const stored = images.filter((image) => image.storagePath);
51
+ if (!stored.length) return text;
52
+ return [
53
+ ...(text.trim() ? [{ type: 'text', text }] : []),
54
+ ...stored.map(({ mimeType, storagePath, width, height }) => ({ type: 'image', mimeType, storagePath, ...(width ? { width } : {}), ...(height ? { height } : {}) })),
55
+ ];
56
+ }
57
+
8
58
  export function writePrivateJson(file, data) {
9
59
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
10
60
  const temporary = `${file}.${process.pid}.tmp`;
@@ -55,8 +105,11 @@ export function runLocalAgent(config, request, { signal, timeoutMs = 180000 } =
55
105
  if (config.profile) args.push('--profile', config.profile);
56
106
  args.push('chat', '--quiet', '--query-file', '-');
57
107
  if (request.runtimeSessionId) args.push('--resume', request.runtimeSessionId);
108
+ if (request.imagePaths?.[0]) args.push('--image', request.imagePaths[0]);
58
109
  }
59
- const child = spawn(config.command[0], args, { env: config.hermesHome ? { ...process.env, HERMES_HOME: config.hermesHome } : process.env, cwd: config.cwd, shell: false, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] });
110
+ // The agent's own `oomi` commands (notify, routines, spaces) use this connection by default.
111
+ const env = { ...process.env, ...(config.hermesHome ? { HERMES_HOME: config.hermesHome } : {}), ...(config.connection ? { OOMI_CONNECTION: config.connection } : {}) };
112
+ const child = spawn(config.command[0], args, { env, cwd: config.cwd, shell: false, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] });
60
113
  let output = '';
61
114
  let diagnostics = '';
62
115
  child.stdout.setEncoding('utf8');
@@ -116,8 +169,11 @@ export class LocalAgentRuntime {
116
169
  file(sessionKey) { return path.join(this.directory, 'conversations', createHash('sha256').update(sessionKey).digest('hex') + '.json'); }
117
170
  history(sessionKey) { return readJson(this.file(sessionKey), { messages: [], receipts: {} }); }
118
171
  async turn({ sessionKey, message, idempotencyKey, attachments = [], retryInterrupted = false }) {
119
- if (typeof sessionKey !== 'string' || !sessionKey || typeof idempotencyKey !== 'string' || !idempotencyKey || typeof message !== 'string' || !message.trim()) throw new Error('A session, message, and idempotency key are required.');
120
- if (attachments.length) throw new Error('This adapter supports text messages. Attachments require a runtime adapter that supports them.');
172
+ const images = readImageAttachments(attachments);
173
+ const text = typeof message === 'string' ? message : '';
174
+ if (typeof sessionKey !== 'string' || !sessionKey || typeof idempotencyKey !== 'string' || !idempotencyKey || (!text.trim() && !images.length)) throw new Error('A session, message, and idempotency key are required.');
175
+ const prompt = text.trim() ? text : 'Here is a photo.';
176
+ message = text;
121
177
  const key = `${sessionKey}\0${idempotencyKey}`;
122
178
  if (this.active.has(key)) {
123
179
  const active = this.active.get(key);
@@ -136,15 +192,18 @@ export class LocalAgentRuntime {
136
192
  if (controller.signal.aborted) throw new Error('Agent turn cancelled.');
137
193
  state.receipts[receiptKey] = { message, status: 'running' };
138
194
  writePrivateJson(this.file(sessionKey), state);
195
+ let scratch;
139
196
  try {
140
- const result = await runLocalAgent(this.config, { sessionKey, message, idempotencyKey, history: state.messages, runtimeSessionId: state.runtimeSessionId || this.config.resume }, { signal: controller.signal });
197
+ scratch = images.length ? writeTurnImages(images) : null;
198
+ const result = await runLocalAgent(this.config, { sessionKey, message: prompt, idempotencyKey, history: state.messages, runtimeSessionId: state.runtimeSessionId || this.config.resume, imagePaths: scratch?.paths || [] }, { signal: controller.signal });
141
199
  state.runtimeSessionId = result.sessionId;
142
- state.messages.push({ role: 'user', content: message, timestamp: Date.now() }, { role: 'assistant', content: [{ type: 'text', text: result.text }], timestamp: Date.now() });
200
+ state.messages.push({ role: 'user', content: userContent(text, images), timestamp: Date.now() }, { role: 'assistant', content: [{ type: 'text', text: result.text }], timestamp: Date.now() });
143
201
  state.receipts[receiptKey] = { message, result };
144
202
  writePrivateJson(this.file(sessionKey), state);
145
203
  this.onHealth({ runtimeReady: true, lastReplyAt: new Date().toISOString(), lastErrorMessage: '' });
146
204
  return result;
147
205
  } catch (err) { this.onHealth({ runtimeReady: false, status: 'degraded', lastErrorMessage: err.message }); throw err; }
206
+ finally { if (scratch) fs.rmSync(scratch.directory, { recursive: true, force: true }); }
148
207
  });
149
208
  this.queue = promise.catch(() => {});
150
209
  this.active.set(key, { promise, controller, sessionKey, message, runId: idempotencyKey });
@@ -2,7 +2,7 @@
2
2
  "id": "oomi-ai",
3
3
  "name": "Oomi Channel Plugin",
4
4
  "description": "Oomi connection, Spaces, health context and routines. Run oomi capabilities and oomi instructions; ordinary state updates do not invoke a model.",
5
- "version": "0.7.0",
5
+ "version": "0.7.1",
6
6
  "author": "Oomi",
7
7
  "license": "MIT",
8
8
  "openclawVersion": ">=0.5.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oomi-ai",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Connect OpenClaw, Hermes, and other local agents to Oomi with verified runtime adapters",
5
5
  "bin": {
6
6
  "oomi": "bin/oomi-ai.js"
@@ -205,11 +205,14 @@ To reach the user on their phone when they are not in the conversation:
205
205
  oomi notify "Your 7-day plan is ready. Want to look now?" --title "Fitness" --json
206
206
  ```
207
207
 
208
+ Add `--connection NAME` when you run it outside an Oomi conversation (for example from a scheduled job); turns Oomi starts already set `OOMI_CONNECTION`. Omit it for the default OpenClaw connection.
209
+
208
210
  Rules:
209
211
  - notify only for things worth interrupting the user for (a reminder they asked for, a finished task, something time-sensitive); say everything else in chat
210
212
  - keep it short and specific; the title defaults to your agent name
211
213
  - tapping it opens the Oomi app to the conversation, where the message also appears
212
- - messages you send on the Oomi channel already notify the phone; do not also run `oomi notify` for the same message
214
+ - replies to the user's own Oomi messages and OpenClaw sends on the Oomi channel already notify the phone; do not also run `oomi notify` for the same message
215
+ - anything you start on your own (a reminder, finished background work) needs `oomi notify`; Hermes agents have no other way to reach the phone
213
216
  - if `notification.status` is `skipped`, tell the user `notification.guidance` instead of retrying
214
217
  - if rate limited, wait `retryAfterSeconds` before trying again
215
218
 
@@ -336,3 +339,9 @@ Use `oomi routines pause|resume|end <routine-id> --file action.json` with expect
336
339
  For instructions in another chat, the user must first grant this connection routineManagementEnabled through Oomi Agent settings or `routines settings` using an authenticated Oomi sourceMessageId. Then routine/response mutations can substitute externalInstruction:{channel,messageId,instruction} for sourceMessageId. Record the actual message reference and instruction. Oomi labels this runtime-attested, not independently authenticated. Never turn on the permission yourself or invent external approval. Ownership, data permissions, and primary selection remain separate.
337
340
 
338
341
  The running bridge receives routine.approved, routine.due, routine.changed, routine.response_recorded, and primary_agent.changed. It acknowledges after the runtime accepts each event and retries failures. Refresh canonical state before acting on a delayed event. Due events already have notificationHandled:true; do not send another phone alert or message for every event. Persist useful context for the next conversation. If a follow-up is explicitly linked to an event, include relatedEventId in the notification API so Oomi suppresses an extra push. Keep Oomi credentials private; all /v1/routines, /v1/agent_preferences, /v1/routine_occurrences, and /v1/agent_events requests use the saved device bearer token, never a user ID supplied by the agent.
342
+
343
+ ### Reply first, then share routine cards
344
+
345
+ Always answer the person in your own conversational voice after a routine or Space tool succeeds. Explain what you recommend or what changed, and invite a useful next step when appropriate. A tool result or review card is not the reply. Do not return only routine names, IDs, JSON, or a stock list of chips. Mention the routines that actually belong to this request; do not automatically recreate or list three routines on unrelated requests. Oomi places newly created routine cards beneath the completed reply in that creation turn. Later approvals update the original card in place. Keep talking normally after a user starts or changes a routine; do not require another UI click when their instruction is already clear.
346
+
347
+ Oomi renders simple Markdown in chat: short paragraphs, bullet or numbered lists, and **bold**. Keep replies brief and scannable, and put long plans in a Space note instead of the chat. A Space link in your reply appears as a card, so share it once rather than repeating the URL or listing IDs.
@@ -280,12 +280,15 @@ To reach the user on their phone when they are not in the conversation, send a p
280
280
  oomi notify "Your 7-day plan is ready. Want to look now?" --title "Fitness" --json
281
281
  ```
282
282
 
283
+ Add `--connection NAME` when you run it outside an Oomi conversation (for example from a scheduled job). Turns Oomi starts already set `OOMI_CONNECTION`. Omit it for the default OpenClaw connection.
284
+
283
285
  Rules:
284
286
 
285
287
  - Notify only for things worth interrupting the user for: a reminder they asked for, a finished task, or something time-sensitive. Say everything else in chat.
286
288
  - Keep the message short and specific. The title defaults to your agent name.
287
289
  - Tapping the notification opens the Oomi app to the conversation, where the message also appears.
288
- - Messages you send the user on the Oomi channel already notify their phone. Do not also run `oomi notify` for the same message.
290
+ - Replies to the user's own Oomi messages, and OpenClaw sends on the Oomi channel, already notify their phone. Do not also run `oomi notify` for the same message.
291
+ - Anything you start on your own, such as a reminder or finished background work, needs `oomi notify`. Hermes agents have no other way to reach the phone.
289
292
  - If `notification.status` is `skipped`, tell the user `notification.guidance` instead of retrying.
290
293
  - If the request is rate limited, do not retry until `retryAfterSeconds` has passed.
291
294
 
@@ -341,3 +344,9 @@ Use `oomi routines pause|resume|end <routine-id> --file action.json` with expect
341
344
  For instructions in another chat, the user must first grant this connection routineManagementEnabled through Oomi Agent settings or `routines settings` using an authenticated Oomi sourceMessageId. Then routine/response mutations can substitute externalInstruction:{channel,messageId,instruction} for sourceMessageId. Record the actual message reference and instruction. Oomi labels this runtime-attested, not independently authenticated. Never turn on the permission yourself or invent external approval. Ownership, data permissions, and primary selection remain separate.
342
345
 
343
346
  The running bridge receives routine.approved, routine.due, routine.changed, routine.response_recorded, and primary_agent.changed. Ordinary events sync quietly without a model call; explicitly enabled routine wakes are bounded and are not retried after an uncertain model dispatch. Refresh canonical state before acting on a delayed event. Due events already have notificationHandled:true; do not send another phone alert or message for every event. Persist useful context for the next conversation. If a follow-up is explicitly linked to an event, include relatedEventId in the notification API so Oomi suppresses an extra push. Keep Oomi credentials private; all /v1/routines, /v1/agent_preferences, /v1/routine_occurrences, and /v1/agent_events requests use the saved device bearer token, never a user ID supplied by the agent.
347
+
348
+ ### Reply first, then share routine cards
349
+
350
+ Always answer the person in your own conversational voice after a routine or Space tool succeeds. Explain what you recommend or what changed, and invite a useful next step when appropriate. A tool result or review card is not the reply. Do not return only routine names, IDs, JSON, or a stock list of chips. Mention the routines that actually belong to this request; do not automatically recreate or list three routines on unrelated requests. Oomi places newly created routine cards beneath the completed reply in that creation turn. Later approvals update the original card in place. Keep talking normally after a user starts or changes a routine; do not require another UI click when their instruction is already clear.
351
+
352
+ Oomi renders simple Markdown in chat: short paragraphs, bullet or numbered lists, and **bold**. Keep replies brief and scannable, and put long plans in a Space note instead of the chat. A Space link in your reply appears as a card, so share it once rather than repeating the URL or listing IDs.