oomi-ai 0.7.2 → 0.8.1

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,64 @@
1
+ import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { readJson, writePrivateJson } from './localAgent.js';
5
+
6
+ export const maintenancePath = (directory) => path.join(directory, 'oomi-bridge-maintenance.json');
7
+ export const readMaintenance = (directory) => readJson(maintenancePath(directory), null);
8
+ const pending = (request) => ['queued', 'restarting'].includes(request?.status);
9
+
10
+ // This is a plan, never an installer: the runtime keeps its normal package approval gate.
11
+ export function planPackageUpdate(current, latest) {
12
+ const valid = (v) => typeof v === 'string' && /^\d+\.\d+\.\d+$/.test(v);
13
+ if (!valid(current) || !valid(latest)) throw new Error('Could not verify the published version. Leave the package and bridge unchanged; try again later.');
14
+ const a = current.split('.').map(Number), b = latest.split('.').map(Number);
15
+ const difference = b.map((v, i) => v - a[i]).find((v) => v !== 0) || 0;
16
+ return { currentVersion: current, latestVersion: latest, status: difference > 0 ? 'update_available' : 'up_to_date',
17
+ ...(difference > 0 ? { installCommand: `npm install -g oomi-ai@${latest}`,
18
+ instruction: 'Run this command alone through your normal approval flow. If blocked or unsuccessful, stop and report the approval needed; do not restart or change security settings. After success, verify the installed version, then run oomi bridge repair for this connection.' }
19
+ : { instruction: 'Already current. Do not reinstall or restart. Check oomi status only if the user also reports a connection problem.' }) };
20
+ }
21
+
22
+ export function isWorkerDescendant(workerPid, { pid = process.pid, run = spawnSync } = {}) {
23
+ if (!Number.isInteger(workerPid) || workerPid <= 0) return false;
24
+ const result = run('ps', ['-axo', 'pid=,ppid='], { encoding: 'utf8', timeout: 5000 });
25
+ if (result.status !== 0) throw new Error('Cannot establish whether this command would stop its own agent. Run maintenance from an independent host terminal.');
26
+ const parents = new Map(String(result.stdout).trim().split('\n').map((line) => line.trim().split(/\s+/).map(Number)));
27
+ const visited = new Set();
28
+ while (pid > 1 && !visited.has(pid)) {
29
+ if (pid === workerPid) return true;
30
+ visited.add(pid); pid = parents.get(pid);
31
+ }
32
+ return false;
33
+ }
34
+
35
+ export function queueRestart(directory, workerPid, now = Date.now()) {
36
+ const previous = readMaintenance(directory);
37
+ if (pending(previous) && previous.workerPid === workerPid && previous.expiresAt > now) return previous;
38
+ const request = { id: randomUUID(), status: 'queued', action: 'restart', workerPid, requestedAt: now, expiresAt: now + 300000,
39
+ message: 'Restart queued. Finish this reply; the bridge will restart after active replies are delivered. Check oomi status afterward; queued is not completed.' };
40
+ writePrivateJson(maintenancePath(directory), request);
41
+ return request;
42
+ }
43
+
44
+ // One host supervisor owns the replacement. Never disable/unload the service from its own job.
45
+ export function createMaintenanceController({ directory, pid = process.pid, isIdle, isConnected, restart, now = Date.now }) {
46
+ let idleSince = null;
47
+ return () => {
48
+ const request = readMaintenance(directory);
49
+ if (!pending(request)) { idleSince = null; return; }
50
+ const finish = (status, message) => writePrivateJson(maintenancePath(directory), { ...request, status, message, finishedAt: now() });
51
+ if (request.workerPid !== pid) {
52
+ if (isConnected()) finish('completed', 'Bridge restarted and reconnected.');
53
+ else if (now() > request.expiresAt) finish('failed', 'The replacement bridge has not reconnected. Check oomi status and the host connection.');
54
+ return;
55
+ }
56
+ if (now() > request.expiresAt) { finish('failed', 'Restart expired while waiting for replies; no further restart will be attempted.'); return; }
57
+ if (request.status !== 'queued') return;
58
+ if (!isIdle()) { idleSince = null; return; }
59
+ idleSince ??= now();
60
+ if (now() - idleSince < 1000) return;
61
+ writePrivateJson(maintenancePath(directory), { ...request, status: 'restarting' });
62
+ try { restart(); } catch (err) { finish('failed', err.message); }
63
+ };
64
+ }
@@ -0,0 +1,180 @@
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' }[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 = { HOME: home, 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 restart() {
162
+ preflight();
163
+ const current = status();
164
+ if (!current.installed || !current.loaded || !current.enabled) throw new Error('Restart requires an enabled, loaded service. Repair it from an independent host terminal.');
165
+ // Submit one supervisor-owned replacement, so killing the caller cannot strand the service.
166
+ invoke(mac ? ['kickstart', '-k', config.target] : ['--no-block', 'restart', config.target]);
167
+ }
168
+ function install({ noStart = false } = {}) {
169
+ assertAvailable();
170
+ writeDefinition();
171
+ stop();
172
+ if (!noStart) start();
173
+ }
174
+ function uninstall() {
175
+ stop();
176
+ if (fs.existsSync(config.file)) fs.unlinkSync(config.file);
177
+ if (!mac) invoke(['daemon-reload']);
178
+ }
179
+ return { config, availability, preflight, status, install, start, stop, restart, uninstall };
180
+ }
package/lib/localAgent.js CHANGED
@@ -165,6 +165,7 @@ export class LocalAgentRuntime {
165
165
  this.onHealth = onHealth;
166
166
  this.queue = Promise.resolve();
167
167
  this.active = new Map();
168
+ this.deliveries = 0;
168
169
  }
169
170
  file(sessionKey) { return path.join(this.directory, 'conversations', createHash('sha256').update(sessionKey).digest('hex') + '.json'); }
170
171
  history(sessionKey) { return readJson(this.file(sessionKey), { messages: [], receipts: {} }); }
@@ -269,6 +270,7 @@ export function startChannelWorker(state, runtime) {
269
270
  return response.json();
270
271
  };
271
272
  const poll = async () => {
273
+ runtime.deliveries++;
272
274
  try {
273
275
  const { messages = [] } = await post('poll', { limit: 1 });
274
276
  for (const message of messages) {
@@ -286,7 +288,7 @@ export function startChannelWorker(state, runtime) {
286
288
  }
287
289
  } catch (err) {
288
290
  runtime.onHealth({ status: 'degraded', lastErrorMessage: err.message });
289
- } finally { if (!stopped) timer = setTimeout(poll, 3000); }
291
+ } finally { runtime.deliveries--; if (!stopped) timer = setTimeout(poll, 3000); }
290
292
  };
291
293
  timer = setTimeout(poll, 0);
292
294
  return () => { stopped = true; clearTimeout(timer); for (const entry of runtime.active.values()) entry.controller.abort(); };
@@ -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
+ }
@@ -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 restore.');
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.7.2",
3
+ "version": "0.8.1",
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 && node --check lib/bridgeMaintenance.js",
53
53
  "test": "node --test test/*.test.mjs"
54
54
  },
55
55
  "dependencies": {
@@ -75,6 +75,9 @@
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",
81
+ "lib/bridgeMaintenance.js"
79
82
  ]
80
83
  }
@@ -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
- Live source example: `{"id":"steps","type":"chart","title":"Steps this week","dataKey":"steps","source":{"kind":"health","metric":"activity.steps","window":"week","unit":"count"}}`. Health sources use canonical metric names from the catalog and resolve permissioned observations when opened. Body-weight unit may be kg or lb. For the user's shared scale use `source:{kind:"scale",metric:"weight",unit:"lb"}`. The scale shows received capture values; packet stability and measurement time remain unverified. No body-composition values can be inferred from weight alone. Don't present estimates as measurements.
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.
@@ -59,9 +127,22 @@ Owner-originated identity changes are a deliberate exception: one notice per ide
59
127
 
60
128
  The legacy persona-app commands below remain compatibility tools; use Spaces for new agent-composed personal views.
61
129
 
130
+ ## Updating Oomi safely
131
+
132
+ When the user asks you to update Oomi, read `oomi instructions` first; it supersedes copied setup/restart recipes. Keep the same connection on the same computer.
133
+
134
+ 1. Run `oomi update --connection NAME` (omit the selector for default OpenClaw). This only checks versions. If already current, report that and stop: do not reinstall or restart "to be sure."
135
+ 2. If an update exists, run the returned exact-version npm install command **alone**, through the runtime's normal approval flow. Do not append redirections, status checks or restarts. If installation is denied, fails, or cannot be verified, leave the bridge alone and explain the approval/error. Never disable security checks, retry through another executor, or enable blanket unattended approvals.
136
+ 3. After successful installation, check `npm list -g oomi-ai --depth=0` and `oomi update` separately. Only then run `oomi bridge repair --connection NAME`.
137
+ 4. A running 0.8.1+ supervised bridge queues its restart until active replies finish and queued phone replies are saved. If the result is `queued`, finish your reply now; do not wait for your own turn to end, poll in a loop, or claim completion. `oomi status --connection NAME --json` subsequently reports maintenance `completed`/`failed` and the running `packageVersion`. Pairing and conversations stay intact.
138
+
139
+ For packages before 0.8.1 (no `oomi update` command), read the published guide, check `npm list -g oomi-ai --depth=0` and `npm view oomi-ai version` in separate commands. If equal, stop. Otherwise install the exact returned version through normal approvals. The first restart of an older or unsupervised bridge must happen from an independent host terminal/agent session, outside the Oomi conversation it would terminate. New CLI commands refuse to stop their own older host. After that bootstrap, in-chat restarts use the deferred path.
140
+
141
+ Hermes single-query mode has no interactive approval surface. If its package/security check requires a human, tell the user to approve/run the exact install in an approval-capable host session, then continue there. A blocked install is not a reason to restart. Oomi does not override host permissions. `bridge repair` is also available for an actual connection fault when no package update is needed.
142
+
62
143
  ## Standard Connect Flow
63
144
 
64
- Install or update the local package:
145
+ For a new installation (for an existing installation, use the safe update flow below):
65
146
 
66
147
  ```bash
67
148
  npm install -g oomi-ai@latest
@@ -134,11 +215,17 @@ Use the SAME connection name from setup. Omit `--name` for default OpenClaw.
134
215
  ```bash
135
216
  oomi status --name hermes --json
136
217
  oomi verify --name hermes
137
- oomi bridge restart --name hermes --detach
218
+ oomi bridge repair --name hermes
138
219
  oomi invite --name hermes --app-url https://www.oomi.ai
139
220
  ```
140
221
 
141
- Do not pair again to refresh an expired Auth Link. Re-running `connect` reuses the saved pairing and verifies the runtime again. 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`. A detached worker requires the computer to remain awake and does not start automatically after reboot. Configure the host's process supervisor when persistence across reboot is needed.
222
+ 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.
223
+
224
+ Run `oomi bridge list` to find saved connection names and their startup protection on this computer. To update an existing connection on **each computer**, follow Updating Oomi safely above. Repair preserves pairing and conversations. On a running 0.8.1+ supervised bridge it queues a safe restart and reports pending work; on a stopped bridge it installs/starts supervision and waits for the broker connection. It does not send a model probe or switch agents. Do not copy connection credentials between computers.
225
+
226
+ 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.
227
+
228
+ 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
229
 
143
230
  `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
231