@ouro.bot/cli 0.1.0-alpha.215 → 0.1.0-alpha.217

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.json CHANGED
@@ -1,6 +1,23 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.217",
6
+ "changes": [
7
+ "feat: Bitwarden vault client (bw CLI wrapper) with credential gateway — BitwardenClient class with SDK-first/CLI-fallback, singleton accessor, nerves events on all operations. Raw secrets never enter model context.",
8
+ "feat: vault tools (vault_get, vault_store, vault_list, vault_delete) with trust gating — read ops require friend+, write/delete require family-only. Destructive operations require confirmation.",
9
+ "feat: stealth browser MCP configuration with trust gating — Playwright MCP auto-provisions when configured, browser tools appear in agent tool list. Trust-gated to CLI and trusted 1:1 only (group chat blocked).",
10
+ "feat: travel API tools (weather_lookup, travel_advisory, geocode_search) — native weather via OpenWeatherMap + vault-backed API key, State Dept RSS feed for advisories, geocoding via Nominatim. All friend+ trust-gated.",
11
+ "feat: credential gateway (vaultKey on apiRequest()) — automatic secret injection from vault into HTTP headers at call time, keeping credentials out of model context entirely.",
12
+ "feat: travel-planning and browser-navigation skills"
13
+ ]
14
+ },
15
+ {
16
+ "version": "0.1.0-alpha.216",
17
+ "changes": [
18
+ "fix: surface tool proactive BB delivery masked by newer MCP/CLI sessions — findFreshestFriendSession picked the single freshest session regardless of channel, so an MCP or CLI session being newer than the BB session caused the BB proactive path to be skipped entirely. Now scans all friend sessions, attempts BB delivery first on any BB session, then falls back to queuing on the freshest non-inner session."
19
+ ]
20
+ },
4
21
  {
5
22
  "version": "0.1.0-alpha.215",
6
23
  "changes": [
@@ -398,14 +398,14 @@ async function startOutlookHttpServer(options = {}) {
398
398
  writeJson(response, 404, { ok: false, error: `unknown agent surface: ${surface}` });
399
399
  return;
400
400
  }
401
- /* v8 ignore start — SPA fallback for client-side routing */
401
+ /* v8 ignore start — SPA fallback for client-side routing @preserve */
402
402
  const spaDir = resolveSpaDistDir();
403
403
  if (spaDir) {
404
404
  if (serveStaticFile(response, path.join(spaDir, "index.html")))
405
405
  return;
406
406
  }
407
- /* v8 ignore stop */
408
407
  writeJson(response, 404, { ok: false, error: `not found: ${pathname}` });
408
+ /* v8 ignore stop */
409
409
  });
410
410
  await new Promise((resolve, reject) => {
411
411
  server.once("error", reject);
@@ -10,12 +10,41 @@ Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.apiRequest = apiRequest;
11
11
  const api_error_1 = require("../heart/api-error");
12
12
  const runtime_1 = require("../nerves/runtime");
13
+ const bitwarden_client_1 = require("./bitwarden-client");
13
14
  /**
14
15
  * Execute an authenticated API request with standard logging and error handling.
15
16
  * Returns the response body as a pretty-printed JSON string.
16
17
  */
17
18
  async function apiRequest(options) {
18
- const { baseUrl, method, path, token, clientName, serviceLabel, connectionName, body, extraHeaders, contentType, eventMeta, } = options;
19
+ const { baseUrl, method, path, token, clientName, serviceLabel, connectionName, body, extraHeaders, contentType, eventMeta, vaultKey, vaultField, } = options;
20
+ // Resolve token from vault if vaultKey is provided
21
+ let resolvedToken = token;
22
+ if (vaultKey) {
23
+ try {
24
+ const client = (0, bitwarden_client_1.getBitwardenClient)();
25
+ if (!client.isConnected()) {
26
+ return (0, api_error_1.handleApiError)(new Error("Vault is locked — connect the vault first"), serviceLabel, connectionName);
27
+ }
28
+ resolvedToken = await client.getRawSecret(vaultKey, vaultField ?? "apiKey");
29
+ (0, runtime_1.emitNervesEvent)({
30
+ event: "client.vault_fetch",
31
+ component: "clients",
32
+ message: `fetched credential from vault for ${serviceLabel}`,
33
+ meta: { client: clientName, vaultKey },
34
+ });
35
+ }
36
+ catch (err) {
37
+ (0, runtime_1.emitNervesEvent)({
38
+ level: "error",
39
+ event: "client.vault_error",
40
+ component: "clients",
41
+ message: `failed to fetch vault credential for ${serviceLabel}`,
42
+ /* v8 ignore next -- defensive: getRawSecret throws Error instances @preserve */
43
+ meta: { client: clientName, vaultKey, reason: err instanceof Error ? err.message : String(err) },
44
+ });
45
+ return (0, api_error_1.handleApiError)(err, serviceLabel, connectionName);
46
+ }
47
+ }
19
48
  try {
20
49
  (0, runtime_1.emitNervesEvent)({
21
50
  event: "client.request_start",
@@ -25,7 +54,7 @@ async function apiRequest(options) {
25
54
  });
26
55
  const url = `${baseUrl}${path}`;
27
56
  const headers = {
28
- Authorization: `Bearer ${token}`,
57
+ Authorization: `Bearer ${resolvedToken}`,
29
58
  "Content-Type": contentType ?? "application/json",
30
59
  ...extraHeaders,
31
60
  };
@@ -0,0 +1,290 @@
1
+ "use strict";
2
+ /**
3
+ * Bitwarden vault client for credential management.
4
+ *
5
+ * SDK-first approach: attempts dynamic import of @bitwarden/sdk-napi,
6
+ * falls back to `bw` CLI with --session flag.
7
+ *
8
+ * Raw secrets are NEVER returned to callers except via getRawSecret(),
9
+ * which is reserved for internal use by the credential gateway (apiRequest vaultKey).
10
+ */
11
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
12
+ if (k2 === undefined) k2 = k;
13
+ var desc = Object.getOwnPropertyDescriptor(m, k);
14
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
15
+ desc = { enumerable: true, get: function() { return m[k]; } };
16
+ }
17
+ Object.defineProperty(o, k2, desc);
18
+ }) : (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ o[k2] = m[k];
21
+ }));
22
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
23
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
24
+ }) : function(o, v) {
25
+ o["default"] = v;
26
+ });
27
+ var __importStar = (this && this.__importStar) || (function () {
28
+ var ownKeys = function(o) {
29
+ ownKeys = Object.getOwnPropertyNames || function (o) {
30
+ var ar = [];
31
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
32
+ return ar;
33
+ };
34
+ return ownKeys(o);
35
+ };
36
+ return function (mod) {
37
+ if (mod && mod.__esModule) return mod;
38
+ var result = {};
39
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
40
+ __setModuleDefault(result, mod);
41
+ return result;
42
+ };
43
+ })();
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.BitwardenClient = void 0;
46
+ exports.getBitwardenClient = getBitwardenClient;
47
+ exports.resetBitwardenClient = resetBitwardenClient;
48
+ const child_process_1 = require("child_process");
49
+ const runtime_1 = require("../nerves/runtime");
50
+ class BitwardenClient {
51
+ sessionKey = null;
52
+ connected = false;
53
+ clientMode = null;
54
+ async connect(config) {
55
+ (0, runtime_1.emitNervesEvent)({
56
+ event: "client.vault_connect_start",
57
+ component: "clients",
58
+ message: "connecting to Bitwarden vault",
59
+ meta: { mode: config.mode ?? "auto" },
60
+ });
61
+ try {
62
+ if (config.mode === "cli") {
63
+ await this.connectCli(config);
64
+ }
65
+ else {
66
+ // Try SDK first, fall back to CLI
67
+ try {
68
+ await this.connectSdk(config);
69
+ }
70
+ catch {
71
+ (0, runtime_1.emitNervesEvent)({
72
+ event: "client.vault_sdk_fallback",
73
+ component: "clients",
74
+ message: "SDK not available, falling back to bw CLI",
75
+ meta: {},
76
+ });
77
+ await this.connectCli(config);
78
+ }
79
+ }
80
+ (0, runtime_1.emitNervesEvent)({
81
+ event: "client.vault_connect_end",
82
+ component: "clients",
83
+ message: "connected to Bitwarden vault",
84
+ meta: { mode: this.clientMode },
85
+ });
86
+ }
87
+ catch (err) {
88
+ (0, runtime_1.emitNervesEvent)({
89
+ level: "error",
90
+ event: "client.vault_connect_error",
91
+ component: "clients",
92
+ message: "failed to connect to Bitwarden vault",
93
+ /* v8 ignore next -- defensive: execCli always wraps in Error @preserve */
94
+ meta: { reason: err instanceof Error ? err.message : String(err) },
95
+ });
96
+ throw err;
97
+ }
98
+ }
99
+ async disconnect() {
100
+ this.sessionKey = null;
101
+ this.connected = false;
102
+ this.clientMode = null;
103
+ (0, runtime_1.emitNervesEvent)({
104
+ event: "client.vault_disconnect",
105
+ component: "clients",
106
+ message: "disconnected from Bitwarden vault",
107
+ meta: {},
108
+ });
109
+ }
110
+ isConnected() {
111
+ return this.connected;
112
+ }
113
+ async getItem(id) {
114
+ this.requireConnected();
115
+ return this.withNervesEvents("getItem", { id }, async () => {
116
+ const raw = await this.fetchRawItem(id);
117
+ return this.stripSecrets(raw);
118
+ });
119
+ }
120
+ async createItem(name, fields) {
121
+ this.requireConnected();
122
+ return this.withNervesEvents("createItem", { name }, async () => {
123
+ const itemFields = Object.entries(fields).map(([k, v]) => ({
124
+ name: k,
125
+ value: v,
126
+ type: 0, // text type
127
+ }));
128
+ const itemTemplate = {
129
+ type: 1, // login type
130
+ name,
131
+ fields: itemFields,
132
+ login: {},
133
+ };
134
+ const encoded = Buffer.from(JSON.stringify(itemTemplate)).toString("base64");
135
+ const stdout = await this.execCli(["create", "item", encoded]);
136
+ const parsed = this.parseCliResponse(stdout);
137
+ return { id: parsed.id, name: parsed.name, type: parsed.type };
138
+ });
139
+ }
140
+ async listItems(search) {
141
+ this.requireConnected();
142
+ return this.withNervesEvents("listItems", { search }, async () => {
143
+ const args = ["list", "items"];
144
+ if (search) {
145
+ args.push("--search", search);
146
+ }
147
+ const stdout = await this.execCli(args);
148
+ const parsed = this.parseCliResponse(stdout);
149
+ const items = Array.isArray(parsed.data) ? parsed.data : (Array.isArray(parsed) ? parsed : []);
150
+ return items.map((item) => ({ id: item.id, name: item.name, type: item.type }));
151
+ });
152
+ }
153
+ async deleteItem(id) {
154
+ this.requireConnected();
155
+ return this.withNervesEvents("deleteItem", { id }, async () => {
156
+ await this.execCli(["delete", "item", id]);
157
+ });
158
+ }
159
+ /**
160
+ * Internal: used by credential gateway (PR 5) to get raw secret for HTTP injection.
161
+ * NOT exposed via tools -- only called by apiRequest() internally.
162
+ */
163
+ async getRawSecret(itemId, fieldName) {
164
+ this.requireConnected();
165
+ return this.withNervesEvents("getRawSecret", { itemId, fieldName }, async () => {
166
+ const raw = await this.fetchRawItem(itemId);
167
+ // Check custom fields first
168
+ if (raw.fields) {
169
+ const field = raw.fields.find((f) => f.name === fieldName);
170
+ if (field)
171
+ return field.value;
172
+ }
173
+ // Check login.password
174
+ if (fieldName === "password" && raw.login?.password) {
175
+ return raw.login.password;
176
+ }
177
+ throw new Error(`field "${fieldName}" not found on vault item "${itemId}"`);
178
+ });
179
+ }
180
+ // --- Private methods ---
181
+ requireConnected() {
182
+ if (!this.connected) {
183
+ throw new Error("vault not connected -- call connect() first");
184
+ }
185
+ }
186
+ async withNervesEvents(operation, meta, fn) {
187
+ (0, runtime_1.emitNervesEvent)({
188
+ event: "client.request_start",
189
+ component: "clients",
190
+ message: `vault ${operation} starting`,
191
+ meta: { operation, ...meta },
192
+ });
193
+ try {
194
+ const result = await fn();
195
+ (0, runtime_1.emitNervesEvent)({
196
+ event: "client.request_end",
197
+ component: "clients",
198
+ message: `vault ${operation} complete`,
199
+ meta: { operation, ...meta },
200
+ });
201
+ return result;
202
+ }
203
+ catch (err) {
204
+ (0, runtime_1.emitNervesEvent)({
205
+ level: "error",
206
+ event: "client.error",
207
+ component: "clients",
208
+ message: `vault ${operation} failed`,
209
+ /* v8 ignore next -- defensive: callers always throw Error instances @preserve */
210
+ meta: { operation, reason: err instanceof Error ? err.message : String(err), ...meta },
211
+ });
212
+ throw err;
213
+ }
214
+ }
215
+ async connectSdk(_config) {
216
+ // Attempt dynamic import of SDK
217
+ // This will throw if the package is not installed
218
+ const sdk = await Promise.resolve(`${"@bitwarden/sdk-napi"}`).then(s => __importStar(require(s)));
219
+ /* v8 ignore start -- SDK not yet published; path tested when package becomes available @preserve */
220
+ const client = new sdk.BitwardenClient({ apiUrl: _config.serverUrl ?? "https://api.bitwarden.com" }, 0);
221
+ await client.auth().loginAccessToken(_config.accessToken, "");
222
+ this.connected = true;
223
+ this.clientMode = "sdk";
224
+ /* v8 ignore stop */
225
+ }
226
+ async connectCli(config) {
227
+ this.sessionKey = config.accessToken;
228
+ // Verify CLI is available and session key works
229
+ await this.execCli(["status"]);
230
+ this.connected = true;
231
+ this.clientMode = "cli";
232
+ }
233
+ async fetchRawItem(id) {
234
+ const stdout = await this.execCli(["get", "item", id]);
235
+ const parsed = this.parseCliResponse(stdout);
236
+ return parsed;
237
+ }
238
+ stripSecrets(raw) {
239
+ const item = {
240
+ id: raw.id,
241
+ name: raw.name,
242
+ type: raw.type,
243
+ };
244
+ if (raw.notes)
245
+ item.notes = raw.notes;
246
+ if (raw.fields)
247
+ item.fields = raw.fields;
248
+ if (raw.login) {
249
+ item.login = {};
250
+ if (raw.login.username)
251
+ item.login.username = raw.login.username;
252
+ if (raw.login.uris)
253
+ item.login.uris = raw.login.uris;
254
+ // password intentionally NOT copied
255
+ }
256
+ return item;
257
+ }
258
+ parseCliResponse(stdout) {
259
+ const parsed = JSON.parse(stdout);
260
+ // bw CLI with --response wraps in { success, data }
261
+ if (parsed.success !== undefined && parsed.data !== undefined) {
262
+ return parsed.data;
263
+ }
264
+ return parsed;
265
+ }
266
+ execCli(args) {
267
+ return new Promise((resolve, reject) => {
268
+ const fullArgs = [...args, "--session", this.sessionKey, "--response"];
269
+ (0, child_process_1.execFile)("bw", fullArgs, { timeout: 30_000 }, (err, stdout, _stderr) => {
270
+ if (err) {
271
+ reject(new Error(`bw CLI error: ${err.message}`));
272
+ return;
273
+ }
274
+ resolve(stdout);
275
+ });
276
+ });
277
+ }
278
+ }
279
+ exports.BitwardenClient = BitwardenClient;
280
+ // Singleton accessor
281
+ let _client = null;
282
+ function getBitwardenClient() {
283
+ if (!_client) {
284
+ _client = new BitwardenClient();
285
+ }
286
+ return _client;
287
+ }
288
+ function resetBitwardenClient() {
289
+ _client = null;
290
+ }
@@ -211,6 +211,26 @@ function resolveOuroSubcommand(command) {
211
211
  return tokens[0];
212
212
  return null;
213
213
  }
214
+ // --- MCP server-specific trust rules ---
215
+ const MCP_SERVER_TRUST = {
216
+ browser: { minTrust: "friend", blockGroupChat: true },
217
+ };
218
+ function checkMcpServerTrust(command, context) {
219
+ const match = command.match(/^ouro\s+mcp\s+call\s+(\S+)/);
220
+ if (!match)
221
+ return allow;
222
+ const serverName = match[1];
223
+ const rules = MCP_SERVER_TRUST[serverName];
224
+ if (!rules)
225
+ return allow; // no special rules for this server
226
+ if (!trustLevelSatisfied(rules.minTrust, context.trustLevel ?? "friend")) {
227
+ return deny(REASONS.needsTrust);
228
+ }
229
+ if (rules.blockGroupChat && context.isGroupChat) {
230
+ return deny("browser tools are only available in 1:1 conversations, not group chats.");
231
+ }
232
+ return allow;
233
+ }
214
234
  function checkSingleShellCommandTrust(command, trustLevel) {
215
235
  const trimmed = command.trim();
216
236
  const tokens = trimmed.split(/\s+/);
@@ -262,8 +282,39 @@ function checkWriteTrustGuardrails(toolName, args, context) {
262
282
  return allow;
263
283
  return deny(REASONS.needsTrustForWrite);
264
284
  }
285
+ // --- vault tool trust gating ---
286
+ // Vault write tools: family only
287
+ const VAULT_FAMILY_TOOLS = new Set(["vault_store", "vault_delete"]);
288
+ // Vault read tools: friend+
289
+ const VAULT_TRUSTED_TOOLS = new Set(["vault_get", "vault_list"]);
290
+ // Travel tools: friend+ (weather_lookup accesses vault credentials indirectly;
291
+ // advisory and geocode are public APIs but gated for consistency)
292
+ const TRAVEL_TRUSTED_TOOLS = new Set(["weather_lookup", "travel_advisory", "geocode_search"]);
293
+ function checkVaultTrustGuardrails(toolName, context) {
294
+ if (VAULT_FAMILY_TOOLS.has(toolName)) {
295
+ if (context.trustLevel === "family")
296
+ return allow;
297
+ return deny(REASONS.needsTrust);
298
+ }
299
+ if (VAULT_TRUSTED_TOOLS.has(toolName) || TRAVEL_TRUSTED_TOOLS.has(toolName)) {
300
+ if ((0, types_1.isTrustedLevel)(context.trustLevel))
301
+ return allow;
302
+ return deny(REASONS.needsTrust);
303
+ }
304
+ return allow;
305
+ }
265
306
  function checkTrustLevelGuardrails(toolName, args, context) {
266
- // Trusted levels (family/friend) — no trust guardrails. Undefined defaults to friend.
307
+ // Vault tools have their own trust rules that apply at all levels
308
+ const vaultResult = checkVaultTrustGuardrails(toolName, context);
309
+ if (!vaultResult.allowed)
310
+ return vaultResult;
311
+ // MCP server-specific trust (e.g. browser blocked in group chats) — applies at all trust levels
312
+ if (toolName === "shell") {
313
+ const mcpResult = checkMcpServerTrust(args.command || "", context);
314
+ if (!mcpResult.allowed)
315
+ return mcpResult;
316
+ }
317
+ // Trusted levels (family/friend) — no further trust guardrails. Undefined defaults to friend.
267
318
  if ((0, types_1.isTrustedLevel)(context.trustLevel))
268
319
  return allow;
269
320
  if (toolName === "shell") {
@@ -9,6 +9,8 @@ const tools_session_1 = require("./tools-session");
9
9
  const tools_continuity_1 = require("./tools-continuity");
10
10
  const tools_config_1 = require("./tools-config");
11
11
  const tools_1 = require("./coding/tools");
12
+ const tools_vault_1 = require("./tools-vault");
13
+ const tools_travel_1 = require("./tools-travel");
12
14
  // Re-export flow tools for consumers that import them from tools-base
13
15
  var tools_flow_1 = require("./tools-flow");
14
16
  Object.defineProperty(exports, "ponderTool", { enumerable: true, get: function () { return tools_flow_1.ponderTool; } });
@@ -32,6 +34,8 @@ exports.baseToolDefinitions = [
32
34
  ...tools_continuity_1.continuityToolDefinitions,
33
35
  ...tools_config_1.configToolDefinitions,
34
36
  ...tools_1.codingToolDefinitions,
37
+ ...tools_vault_1.vaultToolDefinitions,
38
+ ...tools_travel_1.travelToolDefinitions,
35
39
  ];
36
40
  // Convenience array of just the tool schemas (no handler/integration metadata).
37
41
  // Used by consumers that need the OpenAI function-tool format.
@@ -124,31 +124,28 @@ exports.surfaceToolDefinition = {
124
124
  }
125
125
  }
126
126
  }
127
- // Priority 2: Freshest friend session (no active threshold — proactive outreach shouldn't be gated by session age)
128
- const freshest = (0, session_activity_1.findFreshestFriendSession)({
129
- sessionsDir,
130
- friendsDir,
131
- agentName,
132
- friendId,
133
- });
134
- if (freshest && freshest.channel !== "inner") {
135
- // Attempt proactive BB delivery
136
- if (freshest.channel === "bluebubbles") {
137
- const { sendProactiveBlueBubblesMessageToSession } = await Promise.resolve().then(() => __importStar(require("../senses/bluebubbles")));
138
- const proactiveResult = await sendProactiveBlueBubblesMessageToSession({
139
- friendId: freshest.friendId,
140
- sessionKey: freshest.key,
141
- text: content,
142
- });
143
- if (proactiveResult.delivered) {
144
- // Inject surfaced content into the target session so it knows what was delivered
145
- const { appendSyntheticAssistantMessage } = await Promise.resolve().then(() => __importStar(require("../mind/context")));
146
- const sessionFilePath = path.join(sessionsDir, freshest.friendId, freshest.channel, `${freshest.key}.json`);
147
- appendSyntheticAssistantMessage(sessionFilePath, `[surfaced from inner dialog] ${content}`);
148
- return { status: "delivered", detail: "via iMessage" };
149
- }
127
+ // Priority 2: Try proactive delivery first, then queue to freshest session
128
+ const allFriendSessions = (0, session_activity_1.listSessionActivity)({ sessionsDir, friendsDir, agentName, activeThresholdMs: Number.MAX_SAFE_INTEGER })
129
+ .filter((s) => s.friendId === friendId && s.channel !== "inner");
130
+ // 2a: Attempt proactive BB delivery on any BB session (prefer freshest)
131
+ const bbSession = allFriendSessions.find((s) => s.channel === "bluebubbles");
132
+ if (bbSession) {
133
+ const { sendProactiveBlueBubblesMessageToSession } = await Promise.resolve().then(() => __importStar(require("../senses/bluebubbles")));
134
+ const proactiveResult = await sendProactiveBlueBubblesMessageToSession({
135
+ friendId: bbSession.friendId,
136
+ sessionKey: bbSession.key,
137
+ text: content,
138
+ });
139
+ if (proactiveResult.delivered) {
140
+ const { appendSyntheticAssistantMessage } = await Promise.resolve().then(() => __importStar(require("../mind/context")));
141
+ const sessionFilePath = path.join(sessionsDir, bbSession.friendId, bbSession.channel, `${bbSession.key}.json`);
142
+ appendSyntheticAssistantMessage(sessionFilePath, `[surfaced from inner dialog] ${content}`);
143
+ return { status: "delivered", detail: "via iMessage" };
150
144
  }
151
- // Queue as pending for next interaction
145
+ }
146
+ // 2b: No proactive delivery — queue to freshest non-inner session
147
+ const freshest = allFriendSessions[0];
148
+ if (freshest) {
152
149
  const { queuePendingMessage, getPendingDir } = await Promise.resolve().then(() => __importStar(require("../mind/pending")));
153
150
  const pendingDir = getPendingDir(agentName, freshest.friendId, freshest.channel, freshest.key);
154
151
  queuePendingMessage(pendingDir, {
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.travelToolDefinitions = void 0;
4
+ const travel_api_client_1 = require("./travel-api-client");
5
+ const runtime_1 = require("../nerves/runtime");
6
+ exports.travelToolDefinitions = [
7
+ {
8
+ tool: {
9
+ type: "function",
10
+ function: {
11
+ name: "weather_lookup",
12
+ description: "Get current weather for a location. Provide either a city name or lat/lon coordinates.",
13
+ parameters: {
14
+ type: "object",
15
+ properties: {
16
+ city: { type: "string", description: "City name (e.g. 'London', 'New York')" },
17
+ lat: { type: "string", description: "Latitude (decimal)" },
18
+ lon: { type: "string", description: "Longitude (decimal)" },
19
+ },
20
+ },
21
+ },
22
+ },
23
+ handler: async (args) => {
24
+ (0, runtime_1.emitNervesEvent)({
25
+ component: "repertoire",
26
+ event: "repertoire.travel_tool_call",
27
+ message: "weather_lookup invoked",
28
+ meta: { tool: "weather_lookup" },
29
+ });
30
+ try {
31
+ if (args.city) {
32
+ const data = await (0, travel_api_client_1.getWeatherByCity)(args.city);
33
+ return JSON.stringify(data, null, 2);
34
+ }
35
+ if (args.lat && args.lon) {
36
+ const data = await (0, travel_api_client_1.getWeather)(parseFloat(args.lat), parseFloat(args.lon));
37
+ return JSON.stringify(data, null, 2);
38
+ }
39
+ return "Please provide either a city name or lat/lon coordinates.";
40
+ }
41
+ catch (err) {
42
+ /* v8 ignore next -- defensive: callers throw Error instances @preserve */
43
+ return `Weather lookup error: ${err instanceof Error ? err.message : String(err)}`;
44
+ }
45
+ },
46
+ summaryKeys: ["city", "lat", "lon"],
47
+ },
48
+ {
49
+ tool: {
50
+ type: "function",
51
+ function: {
52
+ name: "travel_advisory",
53
+ description: "Get US State Department travel advisory for a country. Returns advisory level (1-4) and description.",
54
+ parameters: {
55
+ type: "object",
56
+ properties: {
57
+ country_code: {
58
+ type: "string",
59
+ description: "ISO 3166 alpha-2 country code (e.g. 'US', 'GB', 'AF')",
60
+ },
61
+ },
62
+ required: ["country_code"],
63
+ },
64
+ },
65
+ },
66
+ handler: async (args) => {
67
+ (0, runtime_1.emitNervesEvent)({
68
+ component: "repertoire",
69
+ event: "repertoire.travel_tool_call",
70
+ message: "travel_advisory invoked",
71
+ meta: { tool: "travel_advisory" },
72
+ });
73
+ try {
74
+ const data = await (0, travel_api_client_1.getTravelAdvisory)(args.country_code.toUpperCase());
75
+ return JSON.stringify(data, null, 2);
76
+ }
77
+ catch (err) {
78
+ /* v8 ignore next -- defensive: callers throw Error instances @preserve */
79
+ return `Travel advisory error: ${err instanceof Error ? err.message : String(err)}`;
80
+ }
81
+ },
82
+ summaryKeys: ["country_code"],
83
+ },
84
+ {
85
+ tool: {
86
+ type: "function",
87
+ function: {
88
+ name: "geocode_search",
89
+ description: "Search for locations, addresses, or points of interest. Returns coordinates and display names.",
90
+ parameters: {
91
+ type: "object",
92
+ properties: {
93
+ query: { type: "string", description: "Search query (e.g. 'Eiffel Tower', '123 Main St')" },
94
+ near_lat: { type: "string", description: "Latitude to search near (for POI search)" },
95
+ near_lon: { type: "string", description: "Longitude to search near (for POI search)" },
96
+ radius_km: { type: "string", description: "Search radius in km (default 10)" },
97
+ },
98
+ required: ["query"],
99
+ },
100
+ },
101
+ },
102
+ handler: async (args) => {
103
+ (0, runtime_1.emitNervesEvent)({
104
+ component: "repertoire",
105
+ event: "repertoire.travel_tool_call",
106
+ message: "geocode_search invoked",
107
+ meta: { tool: "geocode_search" },
108
+ });
109
+ try {
110
+ if (args.near_lat && args.near_lon) {
111
+ const radiusKm = args.radius_km ? parseFloat(args.radius_km) : undefined;
112
+ const data = await (0, travel_api_client_1.searchPOI)(args.query, parseFloat(args.near_lat), parseFloat(args.near_lon), radiusKm);
113
+ return JSON.stringify(data, null, 2);
114
+ }
115
+ const data = await (0, travel_api_client_1.geocode)(args.query);
116
+ return JSON.stringify(data, null, 2);
117
+ }
118
+ catch (err) {
119
+ /* v8 ignore next -- defensive: callers throw Error instances @preserve */
120
+ return `Geocode error: ${err instanceof Error ? err.message : String(err)}`;
121
+ }
122
+ },
123
+ summaryKeys: ["query"],
124
+ },
125
+ ];
@@ -0,0 +1,185 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.vaultToolDefinitions = void 0;
4
+ const bitwarden_client_1 = require("./bitwarden-client");
5
+ const runtime_1 = require("../nerves/runtime");
6
+ exports.vaultToolDefinitions = [
7
+ {
8
+ tool: {
9
+ type: "function",
10
+ function: {
11
+ name: "vault_get",
12
+ description: "Retrieve a credential from the Bitwarden vault by ID or name. Returns metadata and fields but never raw passwords.",
13
+ parameters: {
14
+ type: "object",
15
+ properties: {
16
+ id: { type: "string", description: "Vault item ID (preferred)" },
17
+ name: {
18
+ type: "string",
19
+ description: "Search by name (less precise, uses first match)",
20
+ },
21
+ },
22
+ },
23
+ },
24
+ },
25
+ handler: async (args) => {
26
+ (0, runtime_1.emitNervesEvent)({
27
+ component: "repertoire",
28
+ event: "repertoire.vault_tool_call",
29
+ message: "vault_get invoked",
30
+ meta: { tool: "vault_get" },
31
+ });
32
+ try {
33
+ const client = (0, bitwarden_client_1.getBitwardenClient)();
34
+ if (args.id) {
35
+ const item = await client.getItem(args.id);
36
+ return JSON.stringify(item, null, 2);
37
+ }
38
+ if (args.name) {
39
+ const items = await client.listItems(args.name);
40
+ if (items.length === 0) {
41
+ return `No vault item found matching "${args.name}". Try vault_list to see available items.`;
42
+ }
43
+ const item = await client.getItem(items[0].id);
44
+ return JSON.stringify(item, null, 2);
45
+ }
46
+ return "Please provide either an id or name to look up a vault item.";
47
+ }
48
+ catch (err) {
49
+ /* v8 ignore next -- defensive: callers throw Error instances @preserve */
50
+ return `Vault error: ${err instanceof Error ? err.message : String(err)}`;
51
+ }
52
+ },
53
+ summaryKeys: ["id", "name"],
54
+ },
55
+ {
56
+ tool: {
57
+ type: "function",
58
+ function: {
59
+ name: "vault_store",
60
+ description: "Store a new credential in the Bitwarden vault. Supports login credentials, API keys, and structured fields.",
61
+ parameters: {
62
+ type: "object",
63
+ properties: {
64
+ name: { type: "string", description: "Name for the credential" },
65
+ username: { type: "string", description: "Username (optional)" },
66
+ password: { type: "string", description: "Password (optional)" },
67
+ uri: { type: "string", description: "URI/URL (optional)" },
68
+ notes: { type: "string", description: "Notes (optional)" },
69
+ fields: {
70
+ type: "string",
71
+ description: 'JSON string of custom fields, e.g. {"apiKey": "key123", "region": "us-east"}',
72
+ },
73
+ },
74
+ required: ["name"],
75
+ },
76
+ },
77
+ },
78
+ handler: async (args) => {
79
+ (0, runtime_1.emitNervesEvent)({
80
+ component: "repertoire",
81
+ event: "repertoire.vault_tool_call",
82
+ message: "vault_store invoked",
83
+ meta: { tool: "vault_store", name: args.name },
84
+ });
85
+ try {
86
+ const client = (0, bitwarden_client_1.getBitwardenClient)();
87
+ const fields = {};
88
+ if (args.username)
89
+ fields.username = args.username;
90
+ if (args.password)
91
+ fields.password = args.password;
92
+ if (args.uri)
93
+ fields.uri = args.uri;
94
+ if (args.notes)
95
+ fields.notes = args.notes;
96
+ if (args.fields) {
97
+ const parsed = JSON.parse(args.fields);
98
+ Object.assign(fields, parsed);
99
+ }
100
+ const handle = await client.createItem(args.name, fields);
101
+ return JSON.stringify(handle, null, 2);
102
+ }
103
+ catch (err) {
104
+ /* v8 ignore next -- defensive: callers throw Error instances @preserve */
105
+ return `Vault error: ${err instanceof Error ? err.message : String(err)}`;
106
+ }
107
+ },
108
+ confirmationRequired: true,
109
+ summaryKeys: ["name"],
110
+ },
111
+ {
112
+ tool: {
113
+ type: "function",
114
+ function: {
115
+ name: "vault_list",
116
+ description: "List credentials in the Bitwarden vault. Returns names and IDs only, never secrets.",
117
+ parameters: {
118
+ type: "object",
119
+ properties: {
120
+ search: {
121
+ type: "string",
122
+ description: "Optional search filter",
123
+ },
124
+ },
125
+ },
126
+ },
127
+ },
128
+ handler: async (args) => {
129
+ (0, runtime_1.emitNervesEvent)({
130
+ component: "repertoire",
131
+ event: "repertoire.vault_tool_call",
132
+ message: "vault_list invoked",
133
+ meta: { tool: "vault_list" },
134
+ });
135
+ try {
136
+ const client = (0, bitwarden_client_1.getBitwardenClient)();
137
+ const items = await client.listItems(args.search);
138
+ return JSON.stringify(items, null, 2);
139
+ }
140
+ catch (err) {
141
+ /* v8 ignore next -- defensive: callers throw Error instances @preserve */
142
+ return `Vault error: ${err instanceof Error ? err.message : String(err)}`;
143
+ }
144
+ },
145
+ summaryKeys: ["search"],
146
+ },
147
+ {
148
+ tool: {
149
+ type: "function",
150
+ function: {
151
+ name: "vault_delete",
152
+ description: "Delete a credential from the Bitwarden vault by ID.",
153
+ parameters: {
154
+ type: "object",
155
+ properties: {
156
+ id: {
157
+ type: "string",
158
+ description: "ID of the vault item to delete",
159
+ },
160
+ },
161
+ required: ["id"],
162
+ },
163
+ },
164
+ },
165
+ handler: async (args) => {
166
+ (0, runtime_1.emitNervesEvent)({
167
+ component: "repertoire",
168
+ event: "repertoire.vault_tool_call",
169
+ message: "vault_delete invoked",
170
+ meta: { tool: "vault_delete", id: args.id },
171
+ });
172
+ try {
173
+ const client = (0, bitwarden_client_1.getBitwardenClient)();
174
+ await client.deleteItem(args.id);
175
+ return `Vault item ${args.id} deleted successfully.`;
176
+ }
177
+ catch (err) {
178
+ /* v8 ignore next -- defensive: callers throw Error instances @preserve */
179
+ return `Vault delete failed: ${err instanceof Error ? err.message : String(err)}`;
180
+ }
181
+ },
182
+ confirmationRequired: true,
183
+ summaryKeys: ["id"],
184
+ },
185
+ ];
@@ -0,0 +1,224 @@
1
+ "use strict";
2
+ /**
3
+ * Travel API client module.
4
+ *
5
+ * Provides HTTP clients for:
6
+ * - OpenWeatherMap (query-param auth via vault)
7
+ * - US State Dept Travel Advisories (no auth)
8
+ * - Nominatim Geocoding (no auth, requires User-Agent)
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.TRAVEL_ADVISORY_RSS_URL = void 0;
12
+ exports.setWeatherVaultConfig = setWeatherVaultConfig;
13
+ exports.getWeather = getWeather;
14
+ exports.getWeatherByCity = getWeatherByCity;
15
+ exports.parseAdvisoryLevel = parseAdvisoryLevel;
16
+ exports.parseCountryName = parseCountryName;
17
+ exports.parseAdvisoryText = parseAdvisoryText;
18
+ exports.getTravelAdvisory = getTravelAdvisory;
19
+ exports.geocode = geocode;
20
+ exports.reverseGeocode = reverseGeocode;
21
+ exports.searchPOI = searchPOI;
22
+ const runtime_1 = require("../nerves/runtime");
23
+ const bitwarden_client_1 = require("./bitwarden-client");
24
+ // --- OpenWeatherMap ---
25
+ /**
26
+ * Vault item ID and field name for OpenWeatherMap API key.
27
+ * Override via setWeatherVaultConfig() for custom vault layouts.
28
+ */
29
+ let weatherVaultItemId = "openweathermap-api";
30
+ let weatherVaultField = "apiKey";
31
+ function setWeatherVaultConfig(itemId, field) {
32
+ weatherVaultItemId = itemId;
33
+ if (field)
34
+ weatherVaultField = field;
35
+ }
36
+ async function getWeatherApiKey() {
37
+ const client = (0, bitwarden_client_1.getBitwardenClient)();
38
+ return client.getRawSecret(weatherVaultItemId, weatherVaultField);
39
+ }
40
+ async function getWeather(lat, lon) {
41
+ return withNervesEvents("getWeather", { lat, lon }, async () => {
42
+ const apiKey = await getWeatherApiKey();
43
+ const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric`;
44
+ const res = await fetch(url);
45
+ if (!res.ok) {
46
+ throw new Error(`OpenWeatherMap API error: ${res.status} ${res.statusText}`);
47
+ }
48
+ const data = await res.json();
49
+ return parseWeatherResponse(data);
50
+ });
51
+ }
52
+ async function getWeatherByCity(city) {
53
+ return withNervesEvents("getWeatherByCity", { city }, async () => {
54
+ const apiKey = await getWeatherApiKey();
55
+ const url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${apiKey}&units=metric`;
56
+ const res = await fetch(url);
57
+ if (!res.ok) {
58
+ throw new Error(`OpenWeatherMap API error: ${res.status} ${res.statusText}`);
59
+ }
60
+ const data = await res.json();
61
+ return parseWeatherResponse(data);
62
+ });
63
+ }
64
+ function parseWeatherResponse(data) {
65
+ return {
66
+ temperature: data.main.temp,
67
+ feelsLike: data.main.feels_like,
68
+ description: data.weather?.[0]?.description ?? "unknown",
69
+ humidity: data.main.humidity,
70
+ windSpeed: data.wind.speed,
71
+ city: data.name,
72
+ country: data.sys.country,
73
+ };
74
+ }
75
+ // --- US State Dept Travel Advisories ---
76
+ /**
77
+ * Official US State Department travel advisories RSS feed.
78
+ * This is the canonical public feed maintained by travel.state.gov.
79
+ * Returns XML/RSS with per-country advisory items.
80
+ */
81
+ exports.TRAVEL_ADVISORY_RSS_URL = "https://travel.state.gov/_res/rss/TAsTWs.xml";
82
+ /**
83
+ * Parse advisory level (1-4) from an RSS item title like
84
+ * "Afghanistan - Level 4: Do Not Travel"
85
+ */
86
+ function parseAdvisoryLevel(title) {
87
+ const match = title.match(/Level\s+(\d)/);
88
+ return match ? parseInt(match[1], 10) : 0;
89
+ }
90
+ /**
91
+ * Parse country name from an RSS item title like
92
+ * "Afghanistan - Level 4: Do Not Travel"
93
+ */
94
+ function parseCountryName(title) {
95
+ const dashIdx = title.indexOf(" - ");
96
+ return dashIdx > 0 ? title.slice(0, dashIdx).trim() : title.trim();
97
+ }
98
+ /**
99
+ * Extract advisory text from title (the part after "Level N: ").
100
+ */
101
+ function parseAdvisoryText(title) {
102
+ const match = title.match(/Level\s+\d:\s*(.+)/);
103
+ return match ? match[1].trim() : title.trim();
104
+ }
105
+ async function getTravelAdvisory(countryCode) {
106
+ return withNervesEvents("getTravelAdvisory", { countryCode }, async () => {
107
+ const res = await fetch(exports.TRAVEL_ADVISORY_RSS_URL, {
108
+ headers: {
109
+ Accept: "application/xml, text/xml",
110
+ },
111
+ });
112
+ if (!res.ok) {
113
+ throw new Error(`Travel advisory API error: ${res.status} ${res.statusText}`);
114
+ }
115
+ const xml = await res.text();
116
+ // Parse RSS items via regex (no XML parser dependency needed).
117
+ // Each <item> contains <title>, <pubDate>, and <category domain="Country-Tag">.
118
+ const itemPattern = /<item>([\s\S]*?)<\/item>/g;
119
+ let match;
120
+ while ((match = itemPattern.exec(xml)) !== null) {
121
+ const block = match[1];
122
+ // Extract country tag from <category domain="Country-Tag">XX</category>
123
+ const tagMatch = block.match(/<category\s+domain="Country-Tag">(.*?)<\/category>/);
124
+ const tag = tagMatch?.[1]?.trim().toUpperCase();
125
+ // The RSS feed uses FIPS country codes (e.g. "AF" for Afghanistan),
126
+ // which sometimes differ from ISO 3166. Try exact match first.
127
+ if (tag !== countryCode.toUpperCase())
128
+ continue;
129
+ const titleMatch = block.match(/<title>(.*?)<\/title>/);
130
+ const title = titleMatch?.[1] ?? "";
131
+ const pubDateMatch = block.match(/<pubDate>(.*?)<\/pubDate>/);
132
+ const pubDate = pubDateMatch?.[1]?.trim() ?? "";
133
+ return {
134
+ countryCode,
135
+ countryName: parseCountryName(title),
136
+ advisoryLevel: parseAdvisoryLevel(title),
137
+ advisoryText: parseAdvisoryText(title),
138
+ lastUpdated: pubDate,
139
+ };
140
+ }
141
+ throw new Error(`No travel advisory found for country code "${countryCode}"`);
142
+ });
143
+ }
144
+ // --- Nominatim Geocoding ---
145
+ const NOMINATIM_BASE = "https://nominatim.openstreetmap.org";
146
+ const NOMINATIM_HEADERS = {
147
+ "User-Agent": "Ouroboros/1.0 (https://github.com/ouroborosbot/ouroboros)",
148
+ Accept: "application/json",
149
+ };
150
+ async function geocode(query) {
151
+ return withNervesEvents("geocode", { query }, async () => {
152
+ const url = `${NOMINATIM_BASE}/search?q=${encodeURIComponent(query)}&format=json`;
153
+ const res = await fetch(url, { headers: NOMINATIM_HEADERS });
154
+ if (!res.ok) {
155
+ throw new Error(`Nominatim geocode error: ${res.status} ${res.statusText}`);
156
+ }
157
+ const data = await res.json();
158
+ return data.map(parseGeoLocation);
159
+ });
160
+ }
161
+ async function reverseGeocode(lat, lon) {
162
+ return withNervesEvents("reverseGeocode", { lat, lon }, async () => {
163
+ const url = `${NOMINATIM_BASE}/reverse?lat=${lat}&lon=${lon}&format=json`;
164
+ const res = await fetch(url, { headers: NOMINATIM_HEADERS });
165
+ if (!res.ok) {
166
+ throw new Error(`Nominatim reverse geocode error: ${res.status} ${res.statusText}`);
167
+ }
168
+ const data = await res.json();
169
+ return parseGeoLocation(data);
170
+ });
171
+ }
172
+ async function searchPOI(query, lat, lon, radiusKm) {
173
+ return withNervesEvents("searchPOI", { query, lat, lon, radiusKm }, async () => {
174
+ const radius = radiusKm ?? 10;
175
+ // Nominatim uses viewbox for bounded search (approximate degrees from km)
176
+ const degOffset = radius / 111; // ~111 km per degree
177
+ const viewbox = `${lon - degOffset},${lat + degOffset},${lon + degOffset},${lat - degOffset}`;
178
+ const url = `${NOMINATIM_BASE}/search?q=${encodeURIComponent(query)}&format=json&viewbox=${viewbox}&bounded=1`;
179
+ const res = await fetch(url, { headers: NOMINATIM_HEADERS });
180
+ if (!res.ok) {
181
+ throw new Error(`Nominatim POI search error: ${res.status} ${res.statusText}`);
182
+ }
183
+ const data = await res.json();
184
+ return data.map(parseGeoLocation);
185
+ });
186
+ }
187
+ function parseGeoLocation(item) {
188
+ return {
189
+ lat: parseFloat(item.lat),
190
+ lon: parseFloat(item.lon),
191
+ displayName: item.display_name,
192
+ type: item.type ?? "unknown",
193
+ };
194
+ }
195
+ // --- Shared nerves event wrapper ---
196
+ async function withNervesEvents(operation, meta, fn) {
197
+ (0, runtime_1.emitNervesEvent)({
198
+ event: "client.request_start",
199
+ component: "clients",
200
+ message: `travel API ${operation} starting`,
201
+ meta: { operation, ...meta },
202
+ });
203
+ try {
204
+ const result = await fn();
205
+ (0, runtime_1.emitNervesEvent)({
206
+ event: "client.request_end",
207
+ component: "clients",
208
+ message: `travel API ${operation} complete`,
209
+ meta: { operation, ...meta },
210
+ });
211
+ return result;
212
+ }
213
+ catch (err) {
214
+ (0, runtime_1.emitNervesEvent)({
215
+ level: "error",
216
+ event: "client.error",
217
+ component: "clients",
218
+ message: `travel API ${operation} failed`,
219
+ /* v8 ignore next -- defensive: callers throw Error instances @preserve */
220
+ meta: { operation, reason: err instanceof Error ? err.message : String(err), ...meta },
221
+ });
222
+ throw err;
223
+ }
224
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.215",
3
+ "version": "0.1.0-alpha.217",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",
@@ -0,0 +1,110 @@
1
+ # Browser Navigation Skill
2
+
3
+ When to use browser tools and how to navigate effectively.
4
+
5
+ ## When to Use Browser Tools
6
+
7
+ Use browser navigation when:
8
+ - The target site has no API (e.g., Airbnb, VRBO, travel blogs)
9
+ - Content is dynamic or requires JavaScript rendering
10
+ - Login-required pages need session-based access
11
+ - You need to verify visual content (screenshots, layouts)
12
+ - Price comparison requires real-time scraping
13
+
14
+ Do NOT use browser tools when:
15
+ - A dedicated API or MCP server exists (prefer `weather_lookup`, Duffel MCP, etc.)
16
+ - The data is available via a public JSON endpoint
17
+ - The task can be completed with `web_search`
18
+
19
+ ## Stealth Browsing Best Practices
20
+
21
+ The `@playwright/mcp` server is configured with realistic user-agent and viewport settings. To avoid detection on travel sites:
22
+
23
+ - **Add delays between navigations**: Wait 2-5 seconds between page loads. Never hammer requests in rapid succession.
24
+ - **Vary timing**: Use random delays rather than fixed intervals (e.g., 2-5s, not exactly 3s every time).
25
+ - **Avoid predictable patterns**: Don't navigate the same path repeatedly in short succession.
26
+ - **Use realistic scroll behavior**: Scroll gradually through pages rather than jumping to specific elements.
27
+ - **Respect robots.txt**: Check for rate limits and crawling restrictions.
28
+
29
+ ## Navigation Workflow
30
+
31
+ Follow this pattern for every page interaction:
32
+
33
+ 1. **Navigate**: Use `browser_navigate` to load the page
34
+ 2. **Wait**: Allow the page to fully load (use `browser_wait` if needed)
35
+ 3. **Snapshot**: Take a `browser_snapshot` to understand the page structure
36
+ 4. **Extract**: Parse the accessibility tree for relevant data
37
+ 5. **Screenshot** (optional): Use `browser_screenshot` for visual confirmation
38
+
39
+ ## Form Filling Patterns
40
+
41
+ ### Login Flows
42
+ 1. Navigate to the login page
43
+ 2. Take a snapshot to identify form fields
44
+ 3. Use `browser_type` for username/password fields
45
+ 4. Use `browser_click` to submit
46
+ 5. Wait for redirect, then verify login succeeded via snapshot
47
+ 6. NEVER hardcode credentials -- use `vault_get` to retrieve login info
48
+
49
+ ### Search Forms (Hotels, Flights, Rentals)
50
+ 1. Navigate to the search page
51
+ 2. Snapshot to identify input fields
52
+ 3. Fill location/date fields with `browser_type`
53
+ 4. Select options with `browser_select_option` or `browser_click`
54
+ 5. Submit the search
55
+ 6. Wait for results to load (travel sites often have loading animations)
56
+ 7. Snapshot the results page to extract listings
57
+
58
+ ### Booking Forms
59
+ 1. **ALWAYS confirm with the user before proceeding to payment**
60
+ 2. Fill traveler information
61
+ 3. Use vault credentials for payment (via `vault_get` -- NEVER type raw card numbers)
62
+ 4. Screenshot the final review page for user confirmation
63
+ 5. Only click "Book" / "Confirm" after explicit user approval
64
+
65
+ ## Anti-Bot Detection Avoidance
66
+
67
+ The stealth configuration handles most fingerprinting automatically. Additionally:
68
+
69
+ - **Don't access detection endpoints**: Avoid URLs containing "captcha", "challenge", "verify"
70
+ - **Handle CAPTCHAs**: If you encounter a CAPTCHA, pause and inform the user. Do not attempt automated solving.
71
+ - **Rotate viewport sizes occasionally**: Use different viewport sizes across sessions
72
+ - **Maintain cookies**: Use persistent `user-data-dir` to appear as a returning user
73
+ - **Avoid headless tells**: The user-agent is set to a real browser string
74
+
75
+ ## Travel Site Patterns
76
+
77
+ ### Airbnb / VRBO
78
+ 1. Search by location + check-in/check-out dates + guests
79
+ 2. Results load dynamically -- scroll to load more listings
80
+ 3. Extract: title, price per night, total price, rating, number of reviews
81
+ 4. For detailed info, click into each listing and snapshot
82
+ 5. Compare top 3-5 options
83
+
84
+ ### Hotel Sites (Booking.com, Hotels.com)
85
+ 1. Search by destination + dates + guests + rooms
86
+ 2. Filter by price range, star rating, amenities
87
+ 3. Extract: name, price, location, rating, key amenities
88
+ 4. Check cancellation policies (important for travel planning)
89
+
90
+ ### Flight Comparison
91
+ 1. Prefer Duffel MCP for flight search (structured API data)
92
+ 2. Use browser only if Duffel doesn't cover the airline
93
+ 3. Google Flights is useful for price comparison but requires careful navigation
94
+
95
+ ## Error Handling
96
+
97
+ - **Page timeouts**: Retry once after 5 seconds. If still failing, inform the user.
98
+ - **CAPTCHAs**: Stop and ask the user to solve manually.
99
+ - **Stale elements**: Re-snapshot the page and retry the interaction.
100
+ - **Blocked/403**: The site may have detected automation. Wait 30 seconds and try with a different approach (e.g., direct URL instead of navigation).
101
+ - **Session expired**: Re-login using stored vault credentials.
102
+
103
+ ## Human Confirmation Gates
104
+
105
+ **ALWAYS** confirm with the user before:
106
+ - Any booking or payment action
107
+ - Entering personal information (name, address, phone)
108
+ - Agreeing to terms of service
109
+ - Subscribing to any service
110
+ - Any action that creates a financial obligation
@@ -0,0 +1,143 @@
1
+ # Travel Planning Skill
2
+
3
+ Compose all travel infrastructure to plan and book trips effectively.
4
+
5
+ ## Research Phase
6
+
7
+ ### Destination Research
8
+ 1. Use `geocode_search` to find candidate destinations and get coordinates
9
+ 2. Use `weather_lookup` to check climate for travel dates
10
+ 3. Use `travel_advisory` to check US State Dept safety levels (1-4):
11
+ - Level 1: Exercise Normal Precautions
12
+ - Level 2: Exercise Increased Caution
13
+ - Level 3: Reconsider Travel
14
+ - Level 4: Do Not Travel
15
+ 4. Use browser tools (see `browser-navigation` skill) for destination reviews, travel blogs, local tips
16
+ 5. Compare multiple destinations on weather, safety, cost, and flight availability
17
+
18
+ ### Information Gathering Workflow
19
+ ```
20
+ geocode_search -> get coordinates
21
+ weather_lookup -> check weather at coordinates
22
+ travel_advisory -> check safety level
23
+ browser tools -> reviews, local info, pricing
24
+ ```
25
+
26
+ ## Flight Search
27
+
28
+ ### Using Duffel MCP (when available)
29
+ - Search via `ouro mcp call duffel search_flights`
30
+ - Provide: origin, destination, dates, passengers, cabin class
31
+ - Compare options on price, duration, stops, airline
32
+
33
+ ### Using Browser (fallback)
34
+ - Use browser-navigation skill patterns for Google Flights, airline sites
35
+ - Add delays between searches to avoid detection
36
+
37
+ ### Comparison Criteria
38
+ Present top 3-5 options comparing:
39
+ - Total price (including bags, seat selection)
40
+ - Duration and number of stops
41
+ - Airline and aircraft
42
+ - Departure/arrival times
43
+ - Cancellation/change policy
44
+
45
+ ## Accommodation Search
46
+
47
+ ### Hotel Search
48
+ - Use Expedia MCP when available (`ouro mcp call expedia search_hotels`)
49
+ - Compare: price per night, total stay cost, location, rating, amenities
50
+ - Check cancellation policies (crucial for travel planning)
51
+
52
+ ### Vacation Rentals (Browser-based)
53
+ - Use browser-navigation skill for Airbnb/VRBO
54
+ - Search by location, dates, guests, budget
55
+ - Compare: price, location proximity, reviews, amenities, host rating
56
+ - Screenshot listings for user review
57
+
58
+ ### Comparison Criteria
59
+ - Price per night and total cost
60
+ - Location (distance to key attractions/activities)
61
+ - Reviews and ratings
62
+ - Amenities (WiFi, kitchen, parking, pool)
63
+ - Cancellation flexibility
64
+
65
+ ## Booking Workflow
66
+
67
+ ### Critical Safety Gates
68
+ - **ALWAYS confirm with user before any booking action**
69
+ - **ALWAYS confirm before entering payment information**
70
+ - **ALWAYS confirm before agreeing to terms**
71
+ - **Never proceed with a financial commitment without explicit approval**
72
+
73
+ ### Credential Handling
74
+ - Use `vault_get` to retrieve payment credentials (never expose raw values)
75
+ - Use `vault_get` for loyalty program numbers
76
+ - Use browser-navigation skill form patterns for entering credentials
77
+ - The credential gateway ensures secrets never enter model context
78
+
79
+ ### Post-Booking
80
+ - Save confirmation details (confirmation number, dates, hotel name, airline, booking reference)
81
+ - Save to diary/journal for future reference
82
+ - Set reminders for check-in windows
83
+ - Note cancellation deadlines
84
+
85
+ ## Preference Management
86
+
87
+ ### Storing Preferences
88
+ Track and reference these travel preferences:
89
+ - Preferred airlines and alliance status
90
+ - Preferred hotel chains and loyalty numbers
91
+ - Seat preferences (window/aisle, extra legroom)
92
+ - Dietary needs for in-flight meals
93
+ - Budget ranges (per night for hotels, per flight)
94
+ - Must-have amenities (WiFi, gym, pool)
95
+
96
+ ### Using Preferences
97
+ - Reference stored preferences when searching
98
+ - Apply airline preferences to flight comparisons
99
+ - Apply hotel brand preferences to accommodation searches
100
+ - Adjust budget ranges based on destination
101
+
102
+ ## Tool Reference
103
+
104
+ ### Native Tools (always available)
105
+ - `weather_lookup` - Current weather by city or coordinates
106
+ - `travel_advisory` - US State Dept advisory by country code
107
+ - `geocode_search` - Location/POI search with coordinates
108
+ - `vault_get` - Retrieve credentials (payment, loyalty)
109
+ - `vault_store` - Store new credentials (family trust required)
110
+
111
+ ### MCP Tools (when configured)
112
+ - Browser tools via `@playwright/mcp` - see `browser-navigation` skill
113
+ - Duffel flight search (when MCP server available)
114
+ - Expedia hotel search (when MCP server available)
115
+ - Bitwarden vault management via `@bitwarden/mcp-server` (agent-facing interactive vault access)
116
+
117
+ ### Bitwarden MCP Server
118
+
119
+ For agent-facing vault interaction (browsing items, searching, reading fields), configure
120
+ `@bitwarden/mcp-server` as an MCP server in agent.json:
121
+
122
+ ```json
123
+ {
124
+ "mcpServers": {
125
+ "bitwarden": {
126
+ "command": "npx",
127
+ "args": ["@bitwarden/mcp-server"]
128
+ }
129
+ }
130
+ }
131
+ ```
132
+
133
+ This is distinct from the built-in `bw` CLI-based credential gateway, which handles
134
+ harness-internal secret injection (e.g., API keys injected into tool requests via
135
+ `getRawSecret()`). The MCP server is for the agent to interactively browse and manage
136
+ vault items on behalf of the user.
137
+
138
+ ### Human Confirmation Required For
139
+ - Any booking or payment
140
+ - Entering personal information
141
+ - Agreeing to terms of service
142
+ - Creating financial obligations
143
+ - Sharing credentials with third parties