@ouro.bot/cli 0.1.0-alpha.38 → 0.1.0-alpha.39

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,124 @@
1
+ "use strict";
2
+ // Shared per-turn pipeline for all senses.
3
+ // Senses are thin transport adapters; this module owns the common lifecycle:
4
+ // resolve friend -> trust gate -> load session -> drain pending -> runAgent -> postTurn -> token accumulation.
5
+ //
6
+ // Transport-level concerns (BB API calls, Teams cards, readline) stay in sense adapters.
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.handleInboundTurn = handleInboundTurn;
9
+ const runtime_1 = require("../nerves/runtime");
10
+ // ── Pipeline ──────────────────────────────────────────────────────
11
+ async function handleInboundTurn(input) {
12
+ // Step 1: Resolve friend
13
+ const resolvedContext = await input.friendResolver.resolve();
14
+ (0, runtime_1.emitNervesEvent)({
15
+ component: "senses",
16
+ event: "senses.pipeline_start",
17
+ message: "inbound turn pipeline started",
18
+ meta: {
19
+ channel: input.channel,
20
+ friendId: resolvedContext.friend.id,
21
+ senseType: input.capabilities.senseType,
22
+ },
23
+ });
24
+ // Step 2: Trust gate
25
+ const gateInput = {
26
+ friend: resolvedContext.friend,
27
+ provider: input.provider ?? "local",
28
+ externalId: input.externalId ?? "",
29
+ tenantId: input.tenantId,
30
+ channel: input.channel,
31
+ senseType: input.capabilities.senseType,
32
+ isGroupChat: input.isGroupChat ?? false,
33
+ groupHasFamilyMember: input.groupHasFamilyMember ?? false,
34
+ hasExistingGroupWithFamily: input.hasExistingGroupWithFamily ?? false,
35
+ };
36
+ const gateResult = input.enforceTrustGate(gateInput);
37
+ // Gate rejection: return early, no agent turn
38
+ if (!gateResult.allowed) {
39
+ (0, runtime_1.emitNervesEvent)({
40
+ component: "senses",
41
+ event: "senses.pipeline_gate_reject",
42
+ message: "trust gate rejected inbound turn",
43
+ meta: {
44
+ channel: input.channel,
45
+ friendId: resolvedContext.friend.id,
46
+ reason: gateResult.reason,
47
+ },
48
+ });
49
+ return {
50
+ resolvedContext,
51
+ gateResult,
52
+ };
53
+ }
54
+ // Step 3: Load/create session
55
+ const session = await input.sessionLoader.loadOrCreate();
56
+ const sessionMessages = session.messages;
57
+ // Step 4: Drain pending messages
58
+ const pending = input.drainPending(input.pendingDir);
59
+ // Assemble messages: session messages + pending (formatted) + inbound user messages
60
+ if (pending.length > 0) {
61
+ // Format pending messages and prepend to the user content
62
+ const pendingSection = pending
63
+ .map((msg) => `[pending from ${msg.from}]: ${msg.content}`)
64
+ .join("\n");
65
+ // If there are inbound user messages, prepend pending to the first one
66
+ if (input.messages.length > 0) {
67
+ const firstMsg = input.messages[0];
68
+ if (firstMsg.role === "user") {
69
+ if (typeof firstMsg.content === "string") {
70
+ input.messages[0] = {
71
+ ...firstMsg,
72
+ content: `## pending messages\n${pendingSection}\n\n${firstMsg.content}`,
73
+ };
74
+ }
75
+ else {
76
+ input.messages[0] = {
77
+ ...firstMsg,
78
+ content: [
79
+ { type: "text", text: `## pending messages\n${pendingSection}\n\n` },
80
+ ...firstMsg.content,
81
+ ],
82
+ };
83
+ }
84
+ }
85
+ }
86
+ }
87
+ // Append user messages from the inbound turn
88
+ for (const msg of input.messages) {
89
+ sessionMessages.push(msg);
90
+ }
91
+ // Step 5: runAgent
92
+ const existingToolContext = input.runAgentOptions?.toolContext;
93
+ const runAgentOptions = {
94
+ ...input.runAgentOptions,
95
+ toolContext: {
96
+ /* v8 ignore next -- default no-op signin satisfies interface; real signin injected by sense adapter @preserve */
97
+ signin: async () => undefined,
98
+ ...existingToolContext,
99
+ context: resolvedContext,
100
+ friendStore: input.friendStore,
101
+ },
102
+ };
103
+ const result = await input.runAgent(sessionMessages, input.callbacks, input.channel, input.signal, runAgentOptions);
104
+ // Step 6: postTurn
105
+ input.postTurn(sessionMessages, session.sessionPath, result.usage);
106
+ // Step 7: Token accumulation
107
+ await input.accumulateFriendTokens(input.friendStore, resolvedContext.friend.id, result.usage);
108
+ (0, runtime_1.emitNervesEvent)({
109
+ component: "senses",
110
+ event: "senses.pipeline_end",
111
+ message: "inbound turn pipeline completed",
112
+ meta: {
113
+ channel: input.channel,
114
+ friendId: resolvedContext.friend.id,
115
+ },
116
+ });
117
+ return {
118
+ resolvedContext,
119
+ gateResult,
120
+ usage: result.usage,
121
+ sessionPath: session.sessionPath,
122
+ messages: sessionMessages,
123
+ };
124
+ }
@@ -58,6 +58,7 @@ const commands_1 = require("./commands");
58
58
  const nerves_1 = require("../nerves");
59
59
  const runtime_1 = require("../nerves/runtime");
60
60
  const store_file_1 = require("../mind/friends/store-file");
61
+ const types_1 = require("../mind/friends/types");
61
62
  const resolver_1 = require("../mind/friends/resolver");
62
63
  const tokens_1 = require("../mind/friends/tokens");
63
64
  const turn_coordinator_1 = require("../heart/turn-coordinator");
@@ -65,6 +66,8 @@ const identity_1 = require("../heart/identity");
65
66
  const http = __importStar(require("http"));
66
67
  const path = __importStar(require("path"));
67
68
  const trust_gate_1 = require("./trust-gate");
69
+ const pipeline_1 = require("./pipeline");
70
+ const pending_1 = require("../mind/pending");
68
71
  // Strip @mention markup from incoming messages.
69
72
  // Removes <at>...</at> tags and trims extra whitespace.
70
73
  // Fallback safety net -- the SDK's activity.mentions.stripText should handle
@@ -446,49 +449,24 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
446
449
  // before sync I/O (session load, trim) blocks the event loop.
447
450
  stream.update((0, phrases_1.pickPhrase)((0, phrases_1.getPhrases)().thinking) + "...");
448
451
  await new Promise(r => setImmediate(r));
449
- // Resolve context kernel (identity + channel) early so we can use the friend UUID for session path
452
+ // Resolve identity provider early for friend resolution + slash command session path
450
453
  const store = getFriendStore();
451
454
  const provider = teamsContext?.aadObjectId ? "aad" : "teams-conversation";
452
455
  const externalId = teamsContext?.aadObjectId || conversationId;
453
- const toolContext = teamsContext ? {
454
- graphToken: teamsContext.graphToken,
455
- adoToken: teamsContext.adoToken,
456
- githubToken: teamsContext.githubToken,
457
- signin: teamsContext.signin,
458
- friendStore: store,
459
- summarize: (0, core_1.createSummarize)(),
460
- tenantId: teamsContext.tenantId,
461
- botApi: teamsContext.botApi,
462
- } : undefined;
463
- if (toolContext) {
464
- const resolver = new resolver_1.FriendResolver(store, {
465
- provider,
466
- externalId,
467
- tenantId: teamsContext?.tenantId,
468
- displayName: teamsContext?.displayName || "Unknown",
469
- channel: "teams",
470
- });
471
- toolContext.context = await resolver.resolve();
472
- }
473
- const friendId = toolContext?.context?.friend?.id || "default";
474
- if (toolContext?.context?.friend) {
475
- const trustGate = (0, trust_gate_1.enforceTrustGate)({
476
- friend: toolContext.context.friend,
477
- provider,
478
- externalId,
479
- tenantId: teamsContext?.tenantId,
480
- channel: "teams",
481
- });
482
- if (!trustGate.allowed) {
483
- if (trustGate.reason === "stranger_first_reply") {
484
- stream.emit(trustGate.autoReply);
485
- }
486
- return;
487
- }
488
- }
456
+ // Build FriendResolver for the pipeline
457
+ const resolver = new resolver_1.FriendResolver(store, {
458
+ provider,
459
+ externalId,
460
+ tenantId: teamsContext?.tenantId,
461
+ displayName: teamsContext?.displayName || "Unknown",
462
+ channel: "teams",
463
+ });
464
+ // Pre-resolve friend for session path + slash commands (pipeline will re-use the cached result)
465
+ const resolvedContext = await resolver.resolve();
466
+ const friendId = resolvedContext.friend.id;
489
467
  const registry = (0, commands_1.createCommandRegistry)();
490
468
  (0, commands_1.registerDefaultCommands)(registry);
491
- // Check for slash commands
469
+ // Check for slash commands (before pipeline -- these are transport-level concerns)
492
470
  const parsed = (0, commands_1.parseSlashCommand)(text);
493
471
  if (parsed) {
494
472
  const dispatchResult = registry.dispatch(parsed.command, { channel: "teams" });
@@ -505,35 +483,86 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
505
483
  }
506
484
  }
507
485
  }
508
- // Load or create session
509
- const sessPath = (0, config_2.sessionPath)(friendId, "teams", conversationId);
510
- const existing = (0, context_1.loadSession)(sessPath);
511
- const messages = existing?.messages && existing.messages.length > 0
512
- ? existing.messages
513
- : [{ role: "system", content: await (0, prompt_1.buildSystem)("teams", undefined, toolContext?.context) }];
514
- // Repair any orphaned tool calls from a previous aborted turn
515
- (0, core_1.repairOrphanedToolCalls)(messages);
516
- // Push user message
517
- messages.push({ role: "user", content: text });
518
- // Run agent
486
+ // ── Teams adapter concerns: controller, callbacks, session path ──────────
519
487
  const controller = new AbortController();
520
488
  const channelConfig = (0, config_2.getTeamsChannelConfig)();
521
489
  const callbacks = createTeamsCallbacks(stream, controller, sendMessage, { conversationId, flushIntervalMs: channelConfig.flushIntervalMs });
522
490
  const traceId = (0, nerves_1.createTraceId)();
523
- const agentOptions = {};
524
- agentOptions.traceId = traceId;
525
- if (toolContext)
526
- agentOptions.toolContext = toolContext;
491
+ const sessPath = (0, config_2.sessionPath)(friendId, "teams", conversationId);
492
+ const teamsCapabilities = (0, channel_1.getChannelCapabilities)("teams");
493
+ const pendingDir = (0, pending_1.getPendingDir)((0, identity_1.getAgentName)(), friendId, "teams", conversationId);
494
+ // Build Teams-specific toolContext fields for injection into the pipeline
495
+ const teamsToolContext = teamsContext ? {
496
+ graphToken: teamsContext.graphToken,
497
+ adoToken: teamsContext.adoToken,
498
+ githubToken: teamsContext.githubToken,
499
+ signin: teamsContext.signin,
500
+ summarize: (0, core_1.createSummarize)(),
501
+ tenantId: teamsContext.tenantId,
502
+ botApi: teamsContext.botApi,
503
+ } : {};
504
+ // Build runAgentOptions with Teams-specific fields
505
+ const agentOptions = {
506
+ traceId,
507
+ toolContext: teamsToolContext,
508
+ drainSteeringFollowUps: () => _turnCoordinator.drainFollowUps(turnKey).map((m) => ({ text: m.text })),
509
+ };
527
510
  if (channelConfig.skipConfirmation)
528
511
  agentOptions.skipConfirmation = true;
529
- agentOptions.drainSteeringFollowUps = () => _turnCoordinator.drainFollowUps(turnKey).map((m) => ({ text: m.text }));
530
- const result = await (0, core_1.runAgent)(messages, callbacks, "teams", controller.signal, agentOptions);
512
+ // ── Call shared pipeline ──────────────────────────────────────────
513
+ const result = await (0, pipeline_1.handleInboundTurn)({
514
+ channel: "teams",
515
+ capabilities: teamsCapabilities,
516
+ messages: [{ role: "user", content: text }],
517
+ callbacks,
518
+ friendResolver: { resolve: () => Promise.resolve(resolvedContext) },
519
+ sessionLoader: {
520
+ loadOrCreate: async () => {
521
+ const existing = (0, context_1.loadSession)(sessPath);
522
+ const messages = existing?.messages && existing.messages.length > 0
523
+ ? existing.messages
524
+ : [{ role: "system", content: await (0, prompt_1.buildSystem)("teams", undefined, resolvedContext) }];
525
+ (0, core_1.repairOrphanedToolCalls)(messages);
526
+ return { messages, sessionPath: sessPath };
527
+ },
528
+ },
529
+ pendingDir,
530
+ friendStore: store,
531
+ provider,
532
+ externalId,
533
+ tenantId: teamsContext?.tenantId,
534
+ isGroupChat: false,
535
+ groupHasFamilyMember: false,
536
+ hasExistingGroupWithFamily: false,
537
+ enforceTrustGate: trust_gate_1.enforceTrustGate,
538
+ drainPending: pending_1.drainPending,
539
+ runAgent: (msgs, cb, channel, sig, opts) => (0, core_1.runAgent)(msgs, cb, channel, sig, {
540
+ ...opts,
541
+ toolContext: {
542
+ /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
543
+ signin: async () => undefined,
544
+ ...opts?.toolContext,
545
+ summarize: teamsToolContext.summarize,
546
+ },
547
+ }),
548
+ postTurn: context_1.postTurn,
549
+ accumulateFriendTokens: tokens_1.accumulateFriendTokens,
550
+ signal: controller.signal,
551
+ runAgentOptions: agentOptions,
552
+ });
553
+ // ── Handle gate result ────────────────────────────────────────
554
+ if (!result.gateResult.allowed) {
555
+ if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
556
+ stream.emit(result.gateResult.autoReply);
557
+ }
558
+ return;
559
+ }
531
560
  // Flush any remaining accumulated text at end of turn
532
561
  await callbacks.flush();
533
562
  // After the agent loop, check if any tool returned AUTH_REQUIRED and trigger signin.
534
563
  // This must happen after the stream is done so the OAuth card renders properly.
535
- if (teamsContext) {
536
- const allContent = messages.map(m => typeof m.content === "string" ? m.content : "").join("\n");
564
+ if (teamsContext && result.messages) {
565
+ const allContent = result.messages.map(m => typeof m.content === "string" ? m.content : "").join("\n");
537
566
  if (allContent.includes("AUTH_REQUIRED:graph") && teamsContext.graphConnectionName)
538
567
  await teamsContext.signin(teamsContext.graphConnectionName);
539
568
  if (allContent.includes("AUTH_REQUIRED:ado") && teamsContext.adoConnectionName)
@@ -541,12 +570,6 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
541
570
  if (allContent.includes("AUTH_REQUIRED:github") && teamsContext.githubConnectionName)
542
571
  await teamsContext.signin(teamsContext.githubConnectionName);
543
572
  }
544
- // Trim context and save session
545
- (0, context_1.postTurn)(messages, sessPath, result.usage);
546
- // Accumulate token usage on friend record
547
- if (toolContext?.context?.friend?.id) {
548
- await (0, tokens_1.accumulateFriendTokens)(store, toolContext.context.friend.id, result.usage);
549
- }
550
573
  // SDK auto-closes the stream after our handler returns (app.process.js)
551
574
  }
552
575
  // Internal port for the secondary bot App (not exposed externally).
@@ -739,7 +762,6 @@ function registerBotHandlers(app, label) {
739
762
  (0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.app_error", component: "channels", message: `[${label}] ${msg}`, meta: {} });
740
763
  });
741
764
  }
742
- const TEAMS_PROACTIVE_ALLOWED_TRUST = new Set(["family", "friend"]);
743
765
  function findAadObjectId(friend) {
744
766
  for (const ext of friend.externalIds) {
745
767
  if (ext.provider === "aad" && !ext.externalId.startsWith("group:")) {
@@ -838,7 +860,7 @@ async function drainAndSendPendingTeams(store, botApi, pendingRoot) {
838
860
  });
839
861
  continue;
840
862
  }
841
- if (!TEAMS_PROACTIVE_ALLOWED_TRUST.has(friend.trustLevel ?? "stranger")) {
863
+ if (!types_1.TRUSTED_LEVELS.has(friend.trustLevel ?? "stranger")) {
842
864
  result.skipped++;
843
865
  try {
844
866
  fs.unlinkSync(filePath);
@@ -39,6 +39,10 @@ const fs = __importStar(require("fs"));
39
39
  const path = __importStar(require("path"));
40
40
  const identity_1 = require("../heart/identity");
41
41
  const runtime_1 = require("../nerves/runtime");
42
+ const types_1 = require("../mind/friends/types");
43
+ const pending_1 = require("../mind/pending");
44
+ // TODO: agent should pre-configure auto-reply voice
45
+ // This is a canned reply; in future the agent should compose their own first-contact message
42
46
  exports.STRANGER_AUTO_REPLY = "I'm sorry, I'm not allowed to talk to strangers";
43
47
  function buildExternalKey(provider, externalId, tenantId) {
44
48
  return `${provider}:${tenantId ?? ""}:${externalId}`;
@@ -77,16 +81,107 @@ function appendPrimaryNotification(bundleRoot, provider, externalId, tenantId, n
77
81
  fs.mkdirSync(inboxDir, { recursive: true });
78
82
  fs.appendFileSync(notificationsPath, `${JSON.stringify(payload)}\n`, "utf8");
79
83
  }
84
+ function writeInnerPendingNotice(bundleRoot, noticeContent, nowIso) {
85
+ const innerPendingDir = path.join(bundleRoot, "state", "pending", pending_1.INNER_DIALOG_PENDING.friendId, pending_1.INNER_DIALOG_PENDING.channel, pending_1.INNER_DIALOG_PENDING.key);
86
+ const fileName = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`;
87
+ const filePath = path.join(innerPendingDir, fileName);
88
+ const payload = {
89
+ from: "instinct",
90
+ content: noticeContent,
91
+ timestamp: Date.now(),
92
+ at: nowIso,
93
+ };
94
+ fs.mkdirSync(innerPendingDir, { recursive: true });
95
+ fs.writeFileSync(filePath, JSON.stringify(payload), "utf-8");
96
+ }
80
97
  function enforceTrustGate(input) {
98
+ const { senseType } = input;
99
+ // Local (CLI) and internal (inner dialog) — always allow
100
+ if (senseType === "local" || senseType === "internal") {
101
+ return { allowed: true };
102
+ }
103
+ // Closed senses (Teams) — org already gates access, allow all trust levels
104
+ if (senseType === "closed") {
105
+ return { allowed: true };
106
+ }
107
+ // Open senses (BlueBubbles/iMessage) — enforce trust rules
81
108
  const trustLevel = input.friend.trustLevel ?? "friend";
82
- if (trustLevel !== "stranger") {
109
+ // Family and friend — always allow on open
110
+ if ((0, types_1.isTrustedLevel)(trustLevel)) {
83
111
  return { allowed: true };
84
112
  }
85
113
  const bundleRoot = input.bundleRoot ?? (0, identity_1.getAgentRoot)();
86
- const repliesPath = path.join(bundleRoot, "stranger-replies.json");
87
114
  const nowIso = (input.now ?? (() => new Date()))().toISOString();
115
+ // Acquaintance rules
116
+ if (trustLevel === "acquaintance") {
117
+ return handleAcquaintance(input, bundleRoot, nowIso);
118
+ }
119
+ // Stranger rules (trustLevel === "stranger")
120
+ return handleStranger(input, bundleRoot, nowIso);
121
+ }
122
+ function handleAcquaintance(input, bundleRoot, nowIso) {
123
+ const { isGroupChat, groupHasFamilyMember, hasExistingGroupWithFamily } = input;
124
+ // Group chat with family member present — allow
125
+ if (isGroupChat && groupHasFamilyMember) {
126
+ return { allowed: true };
127
+ }
128
+ let result;
129
+ let noticeDetail;
130
+ if (isGroupChat) {
131
+ // Group chat without family member — reject silently
132
+ result = { allowed: false, reason: "acquaintance_group_no_family" };
133
+ noticeDetail = `acquaintance "${input.friend.name}" messaged in a group chat without a family member present`;
134
+ }
135
+ else if (hasExistingGroupWithFamily) {
136
+ // 1:1 but shares a group with family — redirect
137
+ result = {
138
+ allowed: false,
139
+ reason: "acquaintance_1on1_has_group",
140
+ autoReply: "Hey! Reach me in our group chat instead.",
141
+ };
142
+ noticeDetail = `acquaintance "${input.friend.name}" DMed me directly — redirected to our group chat`;
143
+ }
144
+ else {
145
+ // 1:1, no shared group with family — redirect to any group
146
+ result = {
147
+ allowed: false,
148
+ reason: "acquaintance_1on1_no_group",
149
+ autoReply: "Hey! Reach me in a group chat instead.",
150
+ };
151
+ noticeDetail = `acquaintance "${input.friend.name}" DMed me directly — asked to reach me in a group chat`;
152
+ }
153
+ (0, runtime_1.emitNervesEvent)({
154
+ level: "warn",
155
+ component: "senses",
156
+ event: "senses.trust_gate",
157
+ message: "acquaintance message blocked",
158
+ meta: {
159
+ channel: input.channel,
160
+ provider: input.provider,
161
+ reason: result.reason,
162
+ },
163
+ });
164
+ try {
165
+ writeInnerPendingNotice(bundleRoot, noticeDetail, nowIso);
166
+ }
167
+ catch (error) {
168
+ (0, runtime_1.emitNervesEvent)({
169
+ level: "error",
170
+ component: "senses",
171
+ event: "senses.trust_gate_error",
172
+ message: "failed to write inner pending notice",
173
+ meta: {
174
+ reason: error instanceof Error ? error.message : String(error),
175
+ },
176
+ });
177
+ }
178
+ return result;
179
+ }
180
+ function handleStranger(input, bundleRoot, nowIso) {
181
+ const repliesPath = path.join(bundleRoot, "stranger-replies.json");
88
182
  const externalKey = buildExternalKey(input.provider, input.externalId, input.tenantId);
89
183
  const state = loadRepliesState(repliesPath);
184
+ // Subsequent contact — silent drop
90
185
  if (state[externalKey]) {
91
186
  (0, runtime_1.emitNervesEvent)({
92
187
  level: "warn",
@@ -103,6 +198,7 @@ function enforceTrustGate(input) {
103
198
  reason: "stranger_silent_drop",
104
199
  };
105
200
  }
201
+ // First contact — auto-reply, persist state, notify agent
106
202
  state[externalKey] = nowIso;
107
203
  try {
108
204
  persistRepliesState(repliesPath, state);
@@ -132,6 +228,21 @@ function enforceTrustGate(input) {
132
228
  },
133
229
  });
134
230
  }
231
+ const noticeDetail = `stranger "${input.friend.name}" tried to reach me via ${input.channel}. Auto-replied once.`;
232
+ try {
233
+ writeInnerPendingNotice(bundleRoot, noticeDetail, nowIso);
234
+ }
235
+ catch (error) {
236
+ (0, runtime_1.emitNervesEvent)({
237
+ level: "error",
238
+ component: "senses",
239
+ event: "senses.trust_gate_error",
240
+ message: "failed to write inner pending notice",
241
+ meta: {
242
+ reason: error instanceof Error ? error.message : String(error),
243
+ },
244
+ });
245
+ }
135
246
  (0, runtime_1.emitNervesEvent)({
136
247
  level: "warn",
137
248
  component: "senses",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.38",
3
+ "version": "0.1.0-alpha.39",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",