oomi-ai 0.7.1 → 0.8.0
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/README.md +15 -3
- package/agent_instructions.md +61 -5
- package/bin/oomi-ai.js +154 -242
- package/lib/bridgeService.js +173 -0
- package/lib/localAgent.js +64 -5
- package/lib/objectives.js +34 -0
- package/lib/personaApiClient.js +1 -1
- package/lib/routines.js +1 -1
- package/lib/spaces.js +5 -4
- package/package.json +5 -3
- package/skills/oomi/SKILL.md +81 -4
- package/skills/oomi/agent_instructions.md +74 -21
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
const xml = (value) => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
7
|
+
// systemd performs specifier expansion even inside quotes; ExecStart also expands $.
|
|
8
|
+
const unitString = (value, exec = false) => '"' + String(value).replace(/%/g, '%%').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t').replace(/\$/g, exec ? '$$$$' : '$$') + '"';
|
|
9
|
+
|
|
10
|
+
// These path directives consume the full value, without removing shell-style quotes.
|
|
11
|
+
function unitPath(value) {
|
|
12
|
+
if (/[\\\r\n\0]/.test(value)) throw new Error('Linux service paths cannot contain backslashes or control characters.');
|
|
13
|
+
return String(value).replace(/%/g, '%%');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function bridgeServiceConfig({ name = '', stateDirectory, logPath, scriptPath, nodePath = process.execPath,
|
|
17
|
+
platform = process.platform, home = os.homedir(), env = process.env, uid = process.getuid?.() } = {}) {
|
|
18
|
+
if (name && !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) throw new Error('Invalid connection name.');
|
|
19
|
+
const label = `ai.oomi.bridge${name ? '.' + name : ''}`;
|
|
20
|
+
const directory = path.resolve(stateDirectory);
|
|
21
|
+
const environment = { OOMI_SKIP_UPDATE_CHECK: '1', OOMI_CONNECTION: name, PATH: env.PATH || '/usr/local/bin:/usr/bin:/bin',
|
|
22
|
+
// Always pin the state root. A login service must not resolve another connection from its environment.
|
|
23
|
+
OPENCLAW_HOME: path.resolve(env.OPENCLAW_HOME || path.join(home, '.openclaw')) };
|
|
24
|
+
if (name) environment.OOMI_CONNECTIONS_DIR = path.dirname(directory);
|
|
25
|
+
if (env.OPENCLAW_WORKSPACE) environment.OPENCLAW_WORKSPACE = path.resolve(env.OPENCLAW_WORKSPACE);
|
|
26
|
+
const args = [nodePath, scriptPath, ...(name ? ['bridge', 'start', '--name', name] : ['openclaw', 'bridge', 'start']), '--service-managed'];
|
|
27
|
+
return { name, label, platform, uid, directory, logPath, environment, args,
|
|
28
|
+
file: platform === 'darwin' ? path.join(home, 'Library', 'LaunchAgents', `${label}.plist`)
|
|
29
|
+
: path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'systemd', 'user', `${label}.service`),
|
|
30
|
+
target: platform === 'darwin' ? `gui/${uid}/${label}` : `${label}.service` };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function renderLaunchAgent(config) {
|
|
34
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
35
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
36
|
+
<plist version="1.0"><dict>
|
|
37
|
+
<key>Label</key><string>${xml(config.label)}</string>
|
|
38
|
+
<key>ProgramArguments</key><array>${config.args.map((arg) => `<string>${xml(arg)}</string>`).join('')}</array>
|
|
39
|
+
<key>WorkingDirectory</key><string>${xml(config.directory)}</string>
|
|
40
|
+
<key>RunAtLoad</key><true/>
|
|
41
|
+
<key>KeepAlive</key><true/>
|
|
42
|
+
<key>ThrottleInterval</key><integer>10</integer>
|
|
43
|
+
<key>EnvironmentVariables</key><dict>${Object.entries(config.environment).map(([k, v]) => `<key>${xml(k)}</key><string>${xml(v)}</string>`).join('')}</dict>
|
|
44
|
+
<key>StandardOutPath</key><string>${xml(config.logPath)}</string>
|
|
45
|
+
<key>StandardErrorPath</key><string>${xml(config.logPath)}</string>
|
|
46
|
+
</dict></plist>
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function renderSystemdUnit(config) {
|
|
51
|
+
return `[Unit]
|
|
52
|
+
Description=Oomi bridge ${config.name || 'default'}
|
|
53
|
+
StartLimitIntervalSec=0
|
|
54
|
+
|
|
55
|
+
[Service]
|
|
56
|
+
Type=simple
|
|
57
|
+
WorkingDirectory=${unitPath(config.directory)}
|
|
58
|
+
ExecStart=${config.args.map((arg) => unitString(arg, true)).join(' ')}
|
|
59
|
+
${Object.entries(config.environment).map(([key, value]) => `Environment=${unitString(`${key}=${value}`)}`).join('\n')}
|
|
60
|
+
Restart=always
|
|
61
|
+
RestartSec=10
|
|
62
|
+
TimeoutStopSec=15
|
|
63
|
+
UMask=0077
|
|
64
|
+
StandardOutput=append:${unitPath(config.logPath)}
|
|
65
|
+
StandardError=append:${unitPath(config.logPath)}
|
|
66
|
+
|
|
67
|
+
[Install]
|
|
68
|
+
WantedBy=default.target
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// No credentials, runtime commands or arbitrary environment are copied into service files.
|
|
73
|
+
// Use injectable process execution so lifecycle failures can be tested without real user services.
|
|
74
|
+
export function createBridgeService(config, { run = spawnSync } = {}) {
|
|
75
|
+
const mac = config.platform === 'darwin';
|
|
76
|
+
const supported = mac || config.platform === 'linux';
|
|
77
|
+
const command = mac ? 'launchctl' : 'systemctl';
|
|
78
|
+
function invoke(args, allowFailure = false) {
|
|
79
|
+
const result = run(command, mac ? args : ['--user', ...args], { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
80
|
+
const code = Number.isInteger(result.status) ? result.status : 1;
|
|
81
|
+
const stdout = String(result.stdout || '').trim();
|
|
82
|
+
if (code && !allowFailure) throw new Error(`${command} ${args[0]} failed: ${String(result.stderr || result.error?.message || stdout || 'service manager unavailable').trim()}`);
|
|
83
|
+
return { code, stdout };
|
|
84
|
+
}
|
|
85
|
+
function availability() {
|
|
86
|
+
if (!supported) return { available: false, reason: 'Automatic startup supports macOS launchd and Linux systemd (including WSL with systemd). Use --no-service only with your own supervisor.' };
|
|
87
|
+
const result = invoke(mac ? ['print', `gui/${config.uid}`] : ['show-environment'], true);
|
|
88
|
+
return { available: result.code === 0, reason: result.code ? (mac ? 'A macOS login session is required for the Oomi service.' : 'The systemd user manager is unavailable. Enable systemd/user services, or use --no-service with your own supervisor.') : null };
|
|
89
|
+
}
|
|
90
|
+
function assertAvailable() { const result = availability(); if (!result.available) throw new Error(result.reason); }
|
|
91
|
+
function status() {
|
|
92
|
+
const available = availability();
|
|
93
|
+
const base = { manager: mac ? 'launchd' : config.platform === 'linux' ? 'systemd' : null,
|
|
94
|
+
connection: config.name || null, target: config.target, file: config.file, installed: supported && fs.existsSync(config.file),
|
|
95
|
+
...available, loaded: false, running: false, enabled: false, pid: null, startsAt: mac ? 'login' : 'user-session' };
|
|
96
|
+
if (!available.available) return base;
|
|
97
|
+
if (mac) {
|
|
98
|
+
const printed = invoke(['print', config.target], true);
|
|
99
|
+
const disabled = invoke(['print-disabled', `gui/${config.uid}`], true);
|
|
100
|
+
const escapedLabel = config.label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
101
|
+
const explicitlyDisabled = new RegExp(`"${escapedLabel}"\\s*=>\\s*(?:true|disabled)\\b`).test(disabled.stdout);
|
|
102
|
+
const pid = Number(printed.stdout.match(/\bpid\s*=\s*(\d+)/)?.[1]) || null;
|
|
103
|
+
return { ...base, loaded: printed.code === 0, running: Boolean(pid), pid,
|
|
104
|
+
enabled: base.installed && disabled.code === 0 ? !explicitlyDisabled : null };
|
|
105
|
+
}
|
|
106
|
+
const printed = invoke(['show', config.target, '--property=LoadState,ActiveState,MainPID,UnitFileState'], true);
|
|
107
|
+
const properties = Object.fromEntries(printed.stdout.split('\n').filter((line) => line.includes('=')).map((line) => { const i = line.indexOf('='); return [line.slice(0, i), line.slice(i + 1)]; }));
|
|
108
|
+
const linger = run('loginctl', ['show-user', String(config.uid), '--property=Linger', '--value'], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
109
|
+
const persistsWithoutLogin = linger.status === 0 && String(linger.stdout).trim() === 'yes';
|
|
110
|
+
return { ...base, loaded: properties.LoadState === 'loaded', running: properties.ActiveState === 'active',
|
|
111
|
+
pid: Number(properties.MainPID) || null, enabled: properties.UnitFileState === 'enabled',
|
|
112
|
+
startsAt: persistsWithoutLogin ? 'boot' : 'user-session', persistsWithoutLogin };
|
|
113
|
+
}
|
|
114
|
+
function assertOwnership() {
|
|
115
|
+
// Prevent a custom state root with the same name from taking over another connection's service.
|
|
116
|
+
if (fs.existsSync(config.file)) {
|
|
117
|
+
const existing = fs.readFileSync(config.file, 'utf8');
|
|
118
|
+
const matches = mac ? new RegExp(`<key>WorkingDirectory</key>\\s*<string>${xml(config.directory).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}</string>`).test(existing)
|
|
119
|
+
: existing.includes(`WorkingDirectory=${unitPath(config.directory)}\n`);
|
|
120
|
+
if (!matches) throw new Error(`Service ${config.target} belongs to a different state directory. Use a different connection name.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function preflight() {
|
|
124
|
+
assertAvailable();
|
|
125
|
+
assertOwnership();
|
|
126
|
+
if (config.args[1].split(path.sep).includes('_npx')) throw new Error('Install oomi-ai globally before installing automatic startup; an npx cache is temporary.');
|
|
127
|
+
// Validate paths before pairing or stopping an already working connection.
|
|
128
|
+
if (mac) renderLaunchAgent(config); else renderSystemdUnit(config);
|
|
129
|
+
}
|
|
130
|
+
function writeDefinition() {
|
|
131
|
+
preflight();
|
|
132
|
+
fs.mkdirSync(path.dirname(config.file), { recursive: true });
|
|
133
|
+
fs.mkdirSync(config.directory, { recursive: true });
|
|
134
|
+
fs.mkdirSync(path.dirname(config.logPath), { recursive: true });
|
|
135
|
+
const content = mac ? renderLaunchAgent(config) : renderSystemdUnit(config);
|
|
136
|
+
fs.writeFileSync(config.file, content, { mode: 0o600 });
|
|
137
|
+
fs.chmodSync(config.file, 0o600);
|
|
138
|
+
if (!mac) invoke(['daemon-reload']);
|
|
139
|
+
}
|
|
140
|
+
function stop() {
|
|
141
|
+
assertAvailable();
|
|
142
|
+
assertOwnership();
|
|
143
|
+
if (mac) {
|
|
144
|
+
invoke(['disable', config.target]);
|
|
145
|
+
const current = invoke(['print', config.target], true);
|
|
146
|
+
if (!current.code) invoke(['bootout', config.target]);
|
|
147
|
+
} else if (fs.existsSync(config.file)) invoke(['disable', '--now', config.target]);
|
|
148
|
+
}
|
|
149
|
+
function start({ rewrite = false } = {}) {
|
|
150
|
+
assertAvailable();
|
|
151
|
+
assertOwnership();
|
|
152
|
+
if (!fs.existsSync(config.file)) throw new Error('Bridge service is not installed. Run oomi bridge repair for this connection.');
|
|
153
|
+
const current = status();
|
|
154
|
+
if (rewrite) { writeDefinition(); stop(); }
|
|
155
|
+
if (mac) {
|
|
156
|
+
invoke(['enable', config.target]);
|
|
157
|
+
if (!current.loaded || rewrite) invoke(['bootstrap', `gui/${config.uid}`, config.file]);
|
|
158
|
+
else if (!current.running) invoke(['kickstart', config.target]);
|
|
159
|
+
} else invoke(['enable', '--now', config.target]);
|
|
160
|
+
}
|
|
161
|
+
function install({ noStart = false } = {}) {
|
|
162
|
+
assertAvailable();
|
|
163
|
+
writeDefinition();
|
|
164
|
+
stop();
|
|
165
|
+
if (!noStart) start();
|
|
166
|
+
}
|
|
167
|
+
function uninstall() {
|
|
168
|
+
stop();
|
|
169
|
+
if (fs.existsSync(config.file)) fs.unlinkSync(config.file);
|
|
170
|
+
if (!mac) invoke(['daemon-reload']);
|
|
171
|
+
}
|
|
172
|
+
return { config, availability, preflight, status, install, start, stop, uninstall };
|
|
173
|
+
}
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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:
|
|
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 });
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { routineRequest } from './routines.js';
|
|
3
|
+
|
|
4
|
+
export async function handleObjectivesCommand(state, action = 'list', flags = {}, positionals = [], request = routineRequest) {
|
|
5
|
+
if (!['list', 'show', 'history', 'create', 'update', 'respond'].includes(action)) throw new Error('Use objectives list, show, history, create, update, or respond.');
|
|
6
|
+
let route = 'objectives', method = 'GET', body;
|
|
7
|
+
const id = positionals[0] || flags.id;
|
|
8
|
+
if (!['list', 'create'].includes(action)) {
|
|
9
|
+
if (!/^obj_[a-f0-9]{24}$/.test(id || '')) throw new Error('Use the objective ID returned by Oomi.');
|
|
10
|
+
route += `/${id}`;
|
|
11
|
+
}
|
|
12
|
+
if (action === 'history') {
|
|
13
|
+
if (flags.before && !/^\d{4}-\d{2}-\d{2}$/.test(flags.before)) throw new Error('Use --before YYYY-MM-DD.');
|
|
14
|
+
route += `/history${flags.before ? `?before=${flags.before}` : ''}`;
|
|
15
|
+
}
|
|
16
|
+
if (['create', 'update', 'respond'].includes(action)) {
|
|
17
|
+
if (typeof flags.file !== 'string' || fs.statSync(flags.file).size > 32768) throw new Error('Use a goal JSON file under 32 KiB.');
|
|
18
|
+
body = JSON.parse(fs.readFileSync(flags.file, 'utf8'));
|
|
19
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Use a JSON object.');
|
|
20
|
+
if (flags['request-id']) body.idempotencyKey = flags['request-id'];
|
|
21
|
+
if (flags.revision !== undefined) body.expectedRevision = Number(flags.revision);
|
|
22
|
+
if (flags['source-message']) body.sourceMessageId = flags['source-message'];
|
|
23
|
+
if (typeof body.idempotencyKey !== 'string' || body.idempotencyKey.length < 8) throw new Error('Include a stable idempotencyKey; retry only the identical request.');
|
|
24
|
+
if (action !== 'create' && !Number.isInteger(body.expectedRevision)) throw new Error('Read the goal and provide expectedRevision.');
|
|
25
|
+
if (action === 'respond') {
|
|
26
|
+
if (!body.periodKey || !Number.isInteger(body.expectedPeriodRevision)) throw new Error('Read the goal and include periodKey and expectedPeriodRevision.');
|
|
27
|
+
route += '/responses';
|
|
28
|
+
}
|
|
29
|
+
method = action === 'update' ? 'PATCH' : 'POST';
|
|
30
|
+
}
|
|
31
|
+
const result = await request(state, route, { method, body });
|
|
32
|
+
console.log(JSON.stringify(result, null, 2));
|
|
33
|
+
return result;
|
|
34
|
+
}
|
package/lib/personaApiClient.js
CHANGED
|
@@ -147,7 +147,7 @@ export function createPersonaApiClient({
|
|
|
147
147
|
|
|
148
148
|
getHealthContext(options = {}) {
|
|
149
149
|
const query = new URLSearchParams();
|
|
150
|
-
for (const key of ['window', 'date', 'from', 'to', 'timezone', 'shape', 'groupBy', 'cursor']) {
|
|
150
|
+
for (const key of ['window', 'date', 'from', 'to', 'timezone', 'shape', 'groupBy', 'cursor', 'provider']) {
|
|
151
151
|
if (typeof options[key] === 'string' && options[key].trim()) query.set(key, options[key].trim());
|
|
152
152
|
}
|
|
153
153
|
return getJson({
|
package/lib/routines.js
CHANGED
|
@@ -5,7 +5,7 @@ import { readJson, writePrivateJson } from './localAgent.js';
|
|
|
5
5
|
export async function routineRequest(state, route, { method = 'GET', body, fetchImpl = fetch } = {}) {
|
|
6
6
|
if (!state.deviceToken || !state.brokerHttp) throw new Error('Connect this agent to Oomi first.');
|
|
7
7
|
const response = await fetchImpl(`${state.brokerHttp.replace(/\/$/, '')}/v1/${route}`, {
|
|
8
|
-
method, headers: { Authorization: `Bearer ${state.deviceToken}`, 'Content-Type': 'application/json', 'X-Oomi-Event-Delivery': 'quiet-v1' },
|
|
8
|
+
method, headers: { Authorization: `Bearer ${state.deviceToken}`, 'Content-Type': 'application/json', 'X-Oomi-Event-Delivery': 'quiet-v1', 'X-Oomi-Space-Schema': 'space.v3' },
|
|
9
9
|
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000),
|
|
10
10
|
});
|
|
11
11
|
const result = await response.json().catch(() => ({}));
|
package/lib/spaces.js
CHANGED
|
@@ -9,17 +9,18 @@ export async function handleSpacesCommand(state, action = 'list', flags = {}, po
|
|
|
9
9
|
body = JSON.parse(fs.readFileSync(flags.file, 'utf8'));
|
|
10
10
|
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Use a JSON object.');
|
|
11
11
|
}
|
|
12
|
-
if (!['list', 'catalog', 'create', 'show', 'update', 'archive', 'unarchive', 'revisions', 'restore'].includes(action)) throw new Error('Use spaces list, catalog, create, show, update, archive, unarchive, revisions, or
|
|
12
|
+
if (!['list', 'catalog', 'create', 'show', 'update', 'archive', 'unarchive', 'revisions', 'restore', 'upgrade'].includes(action)) throw new Error('Use spaces list, catalog, create, show, update, archive, unarchive, revisions, restore, or upgrade.');
|
|
13
13
|
if (action === 'catalog') route += '/catalog';
|
|
14
14
|
if (action === 'list' && flags.archived) route += '?archived=true';
|
|
15
15
|
if (!['list', 'catalog', 'create'].includes(action)) {
|
|
16
16
|
if (!/^spc_[a-f0-9]{24}$/.test(id || '')) throw new Error('Provide the Space ID returned by Oomi.');
|
|
17
17
|
route += `/${id}`;
|
|
18
18
|
}
|
|
19
|
-
if (['revisions', 'restore'].includes(action)) route += `/${action}`;
|
|
20
|
-
if (['create', 'update', 'archive', 'unarchive', 'restore'].includes(action)) {
|
|
21
|
-
method = ['create', 'restore'].includes(action) ? 'POST' : 'PATCH';
|
|
19
|
+
if (['revisions', 'restore', 'upgrade'].includes(action)) route += `/${action}`;
|
|
20
|
+
if (['create', 'update', 'archive', 'unarchive', 'restore', 'upgrade'].includes(action)) {
|
|
21
|
+
method = ['create', 'restore', 'upgrade'].includes(action) ? 'POST' : 'PATCH';
|
|
22
22
|
body ||= {};
|
|
23
|
+
if (flags['schema-version']) body.schemaVersion = flags['schema-version'];
|
|
23
24
|
if (flags.revision !== undefined) body.expectedRevision = Number(flags.revision);
|
|
24
25
|
if (flags['request-id']) body.idempotencyKey = flags['request-id'];
|
|
25
26
|
if (action === 'archive' || action === 'unarchive') body.archived = action === 'archive';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oomi-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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"
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"url": "https://github.com/crispcode-io/oomi/issues"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
|
-
"check": "node --check bin/oomi-ai.js",
|
|
52
|
+
"check": "node --check bin/oomi-ai.js && node --check lib/bridgeService.js",
|
|
53
53
|
"test": "node --test test/*.test.mjs"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
@@ -75,6 +75,8 @@
|
|
|
75
75
|
"lib/localAgent.js",
|
|
76
76
|
"lib/agentIdentity.js",
|
|
77
77
|
"lib/routines.js",
|
|
78
|
-
"lib/spaces.js"
|
|
78
|
+
"lib/spaces.js",
|
|
79
|
+
"lib/objectives.js",
|
|
80
|
+
"lib/bridgeService.js"
|
|
79
81
|
]
|
|
80
82
|
}
|
package/skills/oomi/SKILL.md
CHANGED
|
@@ -37,14 +37,82 @@ Supported types and binding fields:
|
|
|
37
37
|
- profile: heightCm, birthDate (YYYY-MM-DD), sex, activityLevel, athleteMode. Ask incrementally only when necessary; do not invent profile facts.
|
|
38
38
|
- routine/streak: routineId returned by the existing routines API. The source remains that routine's real schedule and check-ins. Rendering a component does not activate a routine.
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
For connected content, use reusable named `sources` and a component's `sourceRef`. Check the live catalog for supported versions and available providers. Send `schemaVersion:"space.v2"` on creation and every edit/restore/archive to a v2 Space; flag-only commands accept `--schema-version space.v2`. Retain existing definitions when replacing the sources object. References automatically manage subscriptions; they never grant permission.
|
|
41
|
+
|
|
42
|
+
Example fragment (preserve the person's existing components and targets):
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"schemaVersion": "space.v2",
|
|
46
|
+
"sources": {
|
|
47
|
+
"weight": {"kind":"observation","metric":"body.mass_kg","providers":["smart_scale","apple_health"],"operation":"latest","window":"month","unit":"lb"},
|
|
48
|
+
"workouts": {"kind":"workouts","metric":"workout","operation":"count","window":"week","minDurationMinutes":10}
|
|
49
|
+
},
|
|
50
|
+
"components": [
|
|
51
|
+
{"id":"weight","type":"metric","title":"Latest weight","dataKey":"weight","sourceRef":"weight"},
|
|
52
|
+
{"id":"weightHistory","type":"chart","title":"Weight history","dataKey":"weightHistory","sourceRef":"weight"},
|
|
53
|
+
{"id":"workouts","type":"metric","title":"Recorded workouts","dataKey":"workouts","sourceRef":"workouts"}
|
|
54
|
+
],
|
|
55
|
+
"bindings": {}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Display windows are day (today), week (rolling seven days), month/year (current calendar periods), or all, with an optional IANA timezone. These are not goal recurrence. Latest weight uses a real reading even outside the chart window. Body weight accepts kg/lb. Apple Health daily averages remain in legacy history; individual readings require a sync from the updated phone, and historical averages cannot recover sample times. Scale time is phone receipt time, not a verified device timestamp. Never infer body composition from weight.
|
|
60
|
+
|
|
61
|
+
Use observation operation sum for activity totals, average for averages, or count/presence for evidence. Sensor workouts (kind workouts, metric workout) and user-reported Fitness completions (kind fitness, metric completion, optionally filtered by explicit actionIds) remain separate. Never add those counts and claim duplicates were removed. Missing data is unknown, not zero. observedAt and syncedAt distinguish record time from receipt; dataRevision changes do not affect the edit revision. Quiet space.data_changed events need no model wake or acknowledgment.
|
|
62
|
+
|
|
63
|
+
Legacy inline health and scale sources remain readable. Legacy scale history may contain explicitly shared diagnostic captures, separately labelled and not certified weigh-ins. Use the new observation source for automatic shared readings and selectable chart periods.
|
|
41
64
|
|
|
42
65
|
Removing a component keeps its binding data and revision history. Use `oomi spaces revisions ID` and `oomi spaces restore ID --file restore.json` with `{expectedRevision,restoreRevision,idempotencyKey}` to restore layout without rolling back saved values. Archive/unarchive is reversible. Spaces never execute scripts, HTML, or agent-provided code; unsupported components require an Oomi renderer update.
|
|
43
66
|
|
|
44
67
|
Creating a Space does not authorize health access, identity-file backup, push messages, or schedules. For a requested routine, use the existing routine proposal/commit commands and authenticated sourceMessageId or previously granted externalInstruction permission. When the user says to revise and set it, commit all changes together; the resolved review card disappears. Don't make them repeat the instruction in the UI. The primary agent normally coordinates; specialists keep their own assigned Spaces.
|
|
45
68
|
|
|
69
|
+
### Recurring goals and optional streaks (space.v3)
|
|
70
|
+
|
|
71
|
+
Use the live catalog to confirm support, then `oomi objectives list` and `oomi objectives show ID` before adding another goal. Goals belong to the account and may be reused across Spaces. Keep the existing `goal` card for numeric milestones; use an `objective` component for a recurring rule. A plain checklist does not reset or create completion history.
|
|
72
|
+
|
|
73
|
+
Create with `oomi objectives create --file goal.json`. Example shape only; choose dates, calendar, target and source from the user's request rather than creating this example:
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"title": "Reading practice",
|
|
77
|
+
"idempotencyKey": "unique-goal-create-request",
|
|
78
|
+
"calendar": {"period":"daily","timezone":"America/Chicago","startsOn":"YYYY-MM-DD","weekdays":[1,2,3,4,5]},
|
|
79
|
+
"definition": {"source":{"kind":"manual","unit":"chapters"},"comparison":"gte","target":1}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Calendars support daily (optionally selected weekdays, 1=Monday), weekly (weekStartsOn defaults to Monday), monthly, and once (endsOn required). Time zone and calendar stay fixed through travel. A different calendar requires a new goal. Targets/rules/pause changes take effect next period, leaving past and current progress intact. A future one-time goal may change before starting; once started, correct its progress or create another goal.
|
|
84
|
+
|
|
85
|
+
Sources may be manual, an existing routine (`{kind:"routine",routineId:"returned ID",operation:"count"}`), or a supported observation/workouts/fitness configuration without window/timezone. The calendar supplies its evaluation window. Daily weigh-in uses observation/body.mass_kg/presence/target 1; weekly recorded workouts uses workouts/workout/count and the requested target. Fitness is user-reported and remains separate from sensor workouts. Daily activity totals imported in a different time zone are unknown for this goal, not rebinned or invented. Sources need existing permission; creating goals never grants it.
|
|
86
|
+
|
|
87
|
+
For either/or completion use `{kind:"any",sources:[{kind:"workouts",metric:"workout",operation:"presence"},{kind:"fitness",metric:"completion",operation:"presence"}]}` with comparison `gte` and target `1`. Use 2–5 presence sources (observation, workouts, Fitness, or an existing routine). One period counts once even if several sources match; this does not add workout counts across providers. Nested combinations and manual sources inside this rule are unsupported.
|
|
88
|
+
|
|
89
|
+
Add components using the returned goal ID, preserving all other components and bindings:
|
|
90
|
+
```json
|
|
91
|
+
{"schemaVersion":"space.v3","expectedRevision":1,"idempotencyKey":"unique-space-update-request","components":[{"id":"practice","type":"objective","title":"Reading practice","dataKey":"practice","objectiveId":"RETURNED_OBJECTIVE_ID"},{"id":"practiceStreak","type":"streak","title":"Practice streak","dataKey":"practiceStreak","objectiveIds":["RETURNED_OBJECTIVE_ID"],"match":"all"}]}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Send space.v3 on every write/restore/archive of a Space containing these references. The CLI supplies the space.v3 read header; older clients show an update message and cannot discard newer definitions. Removing a card preserves the objective and history. Never save resolvedBindings back into bindings.
|
|
95
|
+
|
|
96
|
+
For requested changes use `oomi objectives update ID --file change.json` with expectedRevision, stable idempotencyKey, and only the changed title/definition/reminderRoutineId. A definition is replaced as a whole: preserve the source/comparison/target/status not being changed. Set definition.status to active/paused. An optional reminderRoutineId links an existing authorized routine, creates no schedule, and automatically suppresses its reminder once the goal is complete. One routine can follow one goal. A routine-backed goal may link that same routine as its reminder, but not a different routine. Automatic reminder completion is excluded from source evidence so it cannot complete its own goal indefinitely. Create/activate any new reminder through the established routine authority flow.
|
|
97
|
+
|
|
98
|
+
For a person's progress correction use `oomi objectives respond ID --file response.json` with expectedRevision, expectedPeriodRevision, periodKey (all from the latest read), action record/incomplete/skip/reset, and a stable idempotencyKey. Record also requires numeric value; optional note is at most 500 characters. Agent-recorded progress requires sourceMessageId from the authenticated Oomi owner message. The external routine permission does not authorize generic goal responses. Existing routine and Fitness actions still use their own action APIs; goals consume that evidence. Never fabricate completed actions. Corrections persist until reset (return to source data, or clear a manual entry). On uncertain failure retry the identical key/body; after a definite revision conflict read current state and make a new request.
|
|
99
|
+
|
|
100
|
+
`show` contains the last 31 periods; `oomi objectives history ID [--before YYYY-MM-DD]` pages 100 older periods using nextCursor. Full stored history drives current/best streaks, not the seven visible dots. A streak can use 1–10 goals with identical recurring calendars, matching all or any. Rest, skip and pause add no success and do not break an individual streak; an open period is not missed. Skipping one grouped goal does not hide another missed goal. Missing evidence is unknown; unavailable access hides progress. Late source changes/corrections reconcile earlier periods. Existing routine-only streak cards remain compatible and retain their older history limit; migrate explicitly when requested.
|
|
101
|
+
|
|
102
|
+
### Upgrade an existing Space without losing its plan
|
|
103
|
+
|
|
104
|
+
Read the Space and its existing routines first. If the catalog advertises `upgrade`, use `oomi spaces upgrade ID --file upgrade.json` to apply explicit source and goal mappings atomically. Supply `expectedRevision`, a stable `idempotencyKey`, optional named `sources`, optional `sourceBindings:{"EXISTING_COMPONENT_ID":"SOURCE_NAME"}`, and an `objectives` array. Each entry requires a distinct stable `key`, `title`, `calendar`, and `definition` in the usual goal format. Optional fields:
|
|
105
|
+
- `checklistItem:{componentId,itemId}` removes exactly that legacy checklist item.
|
|
106
|
+
- `componentId` chooses the new goal card ID; `replaceComponent:true` explicitly replaces an existing section.
|
|
107
|
+
- `reminderRoutineId` links an existing authorized reminder.
|
|
108
|
+
- `streakComponentIds:["EXISTING_STREAK_ID"]` converts selected streaks to the new goal.
|
|
109
|
+
|
|
110
|
+
All mappings must succeed or nothing changes. Retry the identical body/key after an uncertain response. A conflict requires a fresh read and new request key. Preserve user targets, notes, profiles, unrelated bindings and source permissions. Existing undated checklist ticks are retained in revision history, never invented as dated completions. Use this only for a requested upgrade; do not infer recurrence or health targets from vague prose. Restore an earlier layout with the existing revision API; created account goals and their history remain available.
|
|
111
|
+
|
|
46
112
|
### Health questions without oversized output
|
|
47
113
|
|
|
114
|
+
The iPhone synchronizes available Apple Health history in durable pages keyed by sample UUID, then tracks additions and explicit deletions. A cursor advances only after Oomi saves the page and its affected daily totals. Interrupted imports resume on foreground or a later health update. Apple does not reveal read denials: an empty query is unknown, not zero or proof of deletion. Provider permissions and Oomi's sharing switch still apply. Physical HealthKit/background-delivery acceptance is separate from simulator and synthetic checks.
|
|
115
|
+
|
|
48
116
|
For a rolling seven days: `oomi context health --window week --metric steps --json`.
|
|
49
117
|
For a previous calendar week: determine dates in the user's time zone, then `oomi context health --window range --from YYYY-MM-DD --to YYYY-MM-DD --metric steps --json`.
|
|
50
118
|
Use `--metric weight`, `--metric sleep`, or a canonical metric as needed. Preserve coverage, dates, units, missing days, and today's partial count. Don't label a rolling window as a calendar week. For large/raw responses use `--output /absolute/path/new-health.json`; the CLI writes private, complete JSON to a new file and prints a small receipt. Never try to parse a terminal-truncated response as if it were the original JSON. Read existing permitted history before asking the user to sync. Request a phone sync only when current data is needed; don't repeatedly poll an offline phone.
|
|
@@ -134,11 +202,17 @@ Use the SAME connection name from setup. Omit `--name` for default OpenClaw.
|
|
|
134
202
|
```bash
|
|
135
203
|
oomi status --name hermes --json
|
|
136
204
|
oomi verify --name hermes
|
|
137
|
-
oomi bridge
|
|
205
|
+
oomi bridge repair --name hermes
|
|
138
206
|
oomi invite --name hermes --app-url https://www.oomi.ai
|
|
139
207
|
```
|
|
140
208
|
|
|
141
|
-
Do not pair again to refresh an expired Auth Link. Re-running `connect` reuses
|
|
209
|
+
Do not pair again to refresh an expired Auth Link. Re-running `connect` reuses saved pairing and verification; only the first connection or an explicit `oomi verify` sends a model probe. A different command, profile, or working directory requires a new connection name, so one agent cannot silently replace another. Named connections store private state and logs under `~/.oomi/connections/<name>`; default OpenClaw keeps `~/.openclaw`. Since 0.8.0, `connect` installs a separate automatic-start service for each connection: macOS launchd or Linux systemd user. It restarts an exited worker and starts at the next login/user session. Linux servers can start at boot and remain running after logout when the host administrator enables user lingering (`loginctl enable-linger <user>`); `oomi bridge service status --name hermes` reports whether this is enabled. WSL needs systemd and a running WSL instance; native Windows does not yet have an automatic service installer. A sleeping, powered-off, or disconnected computer remains unavailable.
|
|
210
|
+
|
|
211
|
+
Run `oomi bridge list` to find saved connection names and their startup protection on this computer. To upgrade an existing connection on **each computer**, install `oomi-ai@latest`, then run `oomi bridge repair --name <existing-name>` (omit the name for default OpenClaw). Repair installs/updates its supervisor, preserves pairing and conversations, and waits for the broker connection. It does not send a model probe or switch agents. Do not copy connection credentials between computers.
|
|
212
|
+
|
|
213
|
+
Use `oomi bridge service status --name hermes --json` to inspect startup protection. `oomi bridge stop --name hermes` disables automatic startup and stops only that connection; `oomi bridge start --name hermes` enables it again. `oomi bridge service uninstall --name hermes` removes startup management without deleting saved state. The legacy `oomi openclaw bridge service` command remains supported.
|
|
214
|
+
|
|
215
|
+
For containers or an existing external supervisor, use `connect --no-service` explicitly, then configure that supervisor to run `oomi bridge start --name <name> --service-managed` in the foreground. Detached mode alone does not provide crash/login recovery. Setup fails with an actionable error when the service manager is unavailable; never claim automatic startup in that case. The phone's Reconnect button reconnects the client; it cannot wake a computer or start a missing host process.
|
|
142
216
|
|
|
143
217
|
`connected` describes the broker transport. Inspect `runtimeReady`, `verifiedAt`, and `lastReplyAt` separately. A process ID, Auth Link, or broker connection alone does not prove the agent is answering.
|
|
144
218
|
|
|
@@ -205,11 +279,14 @@ To reach the user on their phone when they are not in the conversation:
|
|
|
205
279
|
oomi notify "Your 7-day plan is ready. Want to look now?" --title "Fitness" --json
|
|
206
280
|
```
|
|
207
281
|
|
|
282
|
+
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.
|
|
283
|
+
|
|
208
284
|
Rules:
|
|
209
285
|
- 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
286
|
- keep it short and specific; the title defaults to your agent name
|
|
211
287
|
- tapping it opens the Oomi app to the conversation, where the message also appears
|
|
212
|
-
- messages
|
|
288
|
+
- 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
|
|
289
|
+
- 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
290
|
- if `notification.status` is `skipped`, tell the user `notification.guidance` instead of retrying
|
|
214
291
|
- if rate limited, wait `retryAfterSeconds` before trying again
|
|
215
292
|
|