@patimweb/pi-teams 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -89,9 +89,54 @@ export interface TenantConfig {
89
89
  permissions?: PermissionBlock;
90
90
  }
91
91
 
92
+ /**
93
+ * Settings for "listen" mode — where pi is woken by an incoming Teams chat.
94
+ *
95
+ * Every field is optional and merges field by field down the cascade
96
+ * (global → account), so an interval can be set once while the chat filter is
97
+ * narrowed per account.
98
+ */
99
+ export interface WatchConfig {
100
+ /** Whether the watcher runs at all (default: false) */
101
+ enabled?: boolean;
102
+ /** Seconds between polls (default: 60, minimum: 15) */
103
+ intervalSeconds?: number;
104
+ /**
105
+ * Glob patterns for the chats to watch — matched against topic, label,
106
+ * chat ID and participant names, exactly like the scope rules. Empty or
107
+ * absent means every recent chat.
108
+ */
109
+ chats?: string[];
110
+ /**
111
+ * Only wake pi for messages from these people — matched against display
112
+ * name, UPN and e-mail. Empty or absent means any sender.
113
+ *
114
+ * This is the "listen to these people" switch: `chats` decides *where* pi
115
+ * listens, `from` decides *to whom*.
116
+ */
117
+ from?: string[];
118
+ /** Only wake pi where the signed-in user is mentioned (default: false) */
119
+ mentionOnly?: boolean;
120
+ /** Seconds to stay quiet in a chat after waking pi for it (default: 300) */
121
+ cooldownSeconds?: number;
122
+ /** Hard cap on wakes per hour, across all chats (default: 10) */
123
+ maxTriggersPerHour?: number;
124
+ }
125
+
126
+ /** A watch config after every default and clamp has been applied. */
127
+ export interface ResolvedWatchConfig {
128
+ enabled: boolean;
129
+ intervalSeconds: number;
130
+ chats: string[];
131
+ from: string[];
132
+ mentionOnly: boolean;
133
+ cooldownSeconds: number;
134
+ maxTriggersPerHour: number;
135
+ }
136
+
92
137
  /** A Teams identity pi can sign in as. */
93
138
  export interface AccountConfig {
94
- /** Short label used in tool parameters, e.g. "neoimpulse" */
139
+ /** Short label used in tool parameters, e.g. "work" */
95
140
  name: string;
96
141
  /** Friendly name for status output */
97
142
  displayName?: string;
@@ -117,6 +162,8 @@ export interface AccountConfig {
117
162
  safetyLevel?: SafetyLevel;
118
163
  /** Scope rules layered on top of the global ones */
119
164
  permissions?: PermissionBlock;
165
+ /** Listen-mode overrides for this identity */
166
+ watch?: WatchConfig;
120
167
  /** Additional tenants this identity reaches */
121
168
  tenants?: TenantConfig[];
122
169
  }
@@ -132,6 +179,8 @@ export interface TeamsRootConfig {
132
179
  safetyLevel?: SafetyLevel;
133
180
  /** Global scope rules */
134
181
  permissions?: PermissionBlock;
182
+ /** Global listen-mode settings */
183
+ watch?: WatchConfig;
135
184
  /** Default page size for message listings (default: 25) */
136
185
  maxMessages?: number;
137
186
  /** Append every write to ~/.pi/agent/pi-teams-audit.jsonl (default: true) */
@@ -161,6 +210,8 @@ export interface TeamsConnection {
161
210
  loopbackPort?: number;
162
211
  safetyLevel: SafetyLevel;
163
212
  permissions: ResolvedPermissions;
213
+ /** Listen-mode settings in force for this account+tenant */
214
+ watch: ResolvedWatchConfig;
164
215
  maxMessages: number;
165
216
  audit: boolean;
166
217
  graphBaseUrl: string;
@@ -220,6 +271,29 @@ const DEFAULTS = {
220
271
  authorityHost: "https://login.microsoftonline.com",
221
272
  };
222
273
 
274
+ /**
275
+ * Listen-mode defaults and the bounds they are clamped to.
276
+ *
277
+ * The lower bound on `intervalSeconds` is what keeps a watcher from turning
278
+ * into a Graph-throttling loop: every tick costs at least one `/me/chats`
279
+ * round-trip, and each wake costs a full model turn.
280
+ */
281
+ export const WATCH_DEFAULTS: ResolvedWatchConfig = {
282
+ enabled: false,
283
+ intervalSeconds: 60,
284
+ chats: [],
285
+ from: [],
286
+ mentionOnly: false,
287
+ cooldownSeconds: 300,
288
+ maxTriggersPerHour: 10,
289
+ };
290
+
291
+ const WATCH_BOUNDS = {
292
+ intervalSeconds: { min: 15, max: 3600 },
293
+ cooldownSeconds: { min: 0, max: 86400 },
294
+ maxTriggersPerHour: { min: 0, max: 1000 },
295
+ };
296
+
223
297
  const VALID_AUTH_MODES = new Set<string>([
224
298
  "interactive",
225
299
  "device-code",
@@ -454,6 +528,7 @@ export function resolveConnection(
454
528
  scopes,
455
529
  safetyLevel: resolveEffectiveSafetyLevel(config.safetyLevel, account, tenant),
456
530
  permissions: resolvePermissions([config.permissions, account.permissions, tenant?.permissions]),
531
+ watch: resolveWatchConfig(config.watch, account),
457
532
  maxMessages: config.maxMessages ?? DEFAULTS.maxMessages,
458
533
  audit: config.audit ?? DEFAULTS.audit,
459
534
  graphBaseUrl: (config.graphBaseUrl ?? DEFAULTS.graphBaseUrl).replace(/\/+$/, ""),
@@ -593,6 +668,75 @@ export function setDefaultAccount(name: string, tenantName?: string): void {
593
668
  writeRootConfig(config);
594
669
  }
595
670
 
671
+ /**
672
+ * Effective listen-mode settings: account > global > defaults.
673
+ *
674
+ * Exported for tests — every field is clamped here, so the watcher itself can
675
+ * assume sane numbers instead of defending against a hand-edited config file.
676
+ */
677
+ export function resolveWatchConfig(
678
+ global: WatchConfig | undefined,
679
+ account: AccountConfig | undefined,
680
+ ): ResolvedWatchConfig {
681
+ const local = account?.watch;
682
+ const pick = <K extends keyof WatchConfig>(key: K): WatchConfig[K] => local?.[key] ?? global?.[key];
683
+
684
+ return {
685
+ enabled: pick("enabled") ?? WATCH_DEFAULTS.enabled,
686
+ intervalSeconds: clamp(pick("intervalSeconds"), WATCH_BOUNDS.intervalSeconds, WATCH_DEFAULTS.intervalSeconds),
687
+ chats: normalizePatterns(pick("chats")),
688
+ from: normalizePatterns(pick("from")),
689
+ mentionOnly: pick("mentionOnly") ?? WATCH_DEFAULTS.mentionOnly,
690
+ cooldownSeconds: clamp(pick("cooldownSeconds"), WATCH_BOUNDS.cooldownSeconds, WATCH_DEFAULTS.cooldownSeconds),
691
+ maxTriggersPerHour: clamp(
692
+ pick("maxTriggersPerHour"),
693
+ WATCH_BOUNDS.maxTriggersPerHour,
694
+ WATCH_DEFAULTS.maxTriggersPerHour,
695
+ ),
696
+ };
697
+ }
698
+
699
+ function clamp(value: unknown, bounds: { min: number; max: number }, fallback: number): number {
700
+ const number = typeof value === "number" && Number.isFinite(value) ? value : fallback;
701
+ return Math.min(bounds.max, Math.max(bounds.min, Math.round(number)));
702
+ }
703
+
704
+ function normalizePatterns(patterns: string[] | undefined): string[] {
705
+ if (!Array.isArray(patterns)) return [];
706
+ const cleaned = patterns
707
+ .filter((pattern): pattern is string => typeof pattern === "string")
708
+ .map((pattern) => pattern.trim())
709
+ .filter((pattern) => pattern.length > 0);
710
+ return [...new Set(cleaned)];
711
+ }
712
+
713
+ /**
714
+ * Merge a watch patch into the config file and report what is now in force.
715
+ *
716
+ * Writes to the account block when an account is named and to the global block
717
+ * otherwise — matching how `watch` itself is resolved. The resolved settings
718
+ * are returned rather than the patch, so a caller reports the effect and not
719
+ * the request.
720
+ */
721
+ export function setWatchConfig(patch: WatchConfig, accountName?: string): ResolvedWatchConfig {
722
+ const config = readRootConfig();
723
+
724
+ if (!accountName) {
725
+ config.watch = { ...config.watch, ...patch };
726
+ writeRootConfig(config);
727
+ return resolveWatchConfig(config.watch, undefined);
728
+ }
729
+
730
+ const account = config.accounts.find((a) => a.name.toLowerCase() === accountName.toLowerCase());
731
+ if (!account) {
732
+ throw new ConfigError([`account "${accountName}"`], `Account "${accountName}" not found.`);
733
+ }
734
+
735
+ account.watch = { ...account.watch, ...patch };
736
+ writeRootConfig(config);
737
+ return resolveWatchConfig(config.watch, account);
738
+ }
739
+
596
740
  /** Set the safety level globally, on an account, or on a tenant. */
597
741
  export function setSafetyLevel(
598
742
  level: SafetyLevel,
@@ -12,6 +12,7 @@ import { Box, Text } from "@earendil-works/pi-tui";
12
12
  import {
13
13
  ensureConfigTemplate,
14
14
  getConfigPath,
15
+ setWatchConfig,
15
16
  tryResolveConnection,
16
17
  type TeamsConnection,
17
18
  } from "../config/index.ts";
@@ -22,6 +23,13 @@ import {
22
23
  isMutationTool,
23
24
  LOCAL_CONFIG_TOOLS,
24
25
  } from "../safety/index.ts";
26
+ import {
27
+ setActiveWatchLoop,
28
+ startWatchLoop,
29
+ stopActiveWatchLoop,
30
+ type WatchLoop,
31
+ } from "../watch/loop.ts";
32
+ import { composeWatchPrompt } from "../watch/prompt.ts";
25
33
 
26
34
  import { teamsSetupTool } from "../tools/teams-setup.ts";
27
35
  import { teamsLoginTool, teamsLogoutTool } from "../tools/teams-login.ts";
@@ -49,6 +57,7 @@ import { teamsSearchMessagesTool } from "../tools/teams-search-messages.ts";
49
57
  import { teamsReactTool } from "../tools/teams-react.ts";
50
58
  import { teamsDeleteMessageTool } from "../tools/teams-delete-message.ts";
51
59
  import { teamsInboxTool } from "../tools/teams-inbox.ts";
60
+ import { teamsWatchTool } from "../tools/teams-watch.ts";
52
61
  import {
53
62
  teamsGetPresenceTool,
54
63
  teamsSetPresenceTool,
@@ -73,6 +82,7 @@ const tools = [
73
82
  teamsAccountsTool,
74
83
  teamsStatusTool,
75
84
  teamsPermissionsTool,
85
+ teamsWatchTool,
76
86
  teamsDoctorTool,
77
87
  // Discovery
78
88
  teamsListTeamsTool,
@@ -118,6 +128,74 @@ export default function (pi: ExtensionAPI) {
118
128
  return connection;
119
129
  };
120
130
 
131
+ // -----------------------------------------------------------------------
132
+ // Listen mode
133
+ // -----------------------------------------------------------------------
134
+
135
+ /** The watcher running in this session, when listen mode is on. */
136
+ let watchLoop: WatchLoop | undefined;
137
+
138
+ /** The last polling error the user was told about, so it is said once. */
139
+ let reportedWatchError: string | undefined;
140
+
141
+ const stopWatch = () => {
142
+ watchLoop?.stop();
143
+ watchLoop = undefined;
144
+ setActiveWatchLoop(undefined);
145
+ };
146
+
147
+ /**
148
+ * Start the watcher for the session's account, if the config asks for it.
149
+ *
150
+ * Restarting is the same call: the old loop is stopped first, so a config
151
+ * change cannot leave two pollers running.
152
+ */
153
+ const startWatch = (ctx: any) => {
154
+ stopWatch();
155
+
156
+ const conn = refresh();
157
+ if (!conn?.watch.enabled) return;
158
+
159
+ reportedWatchError = undefined;
160
+ const loop = startWatchLoop({
161
+ connection: conn,
162
+ onWake: (event) => {
163
+ pi.sendUserMessage(composeWatchPrompt(event, event.me), { deliverAs: "followUp" });
164
+ },
165
+ onTick: (status) => {
166
+ if (!status.lastError) reportedWatchError = undefined;
167
+ },
168
+ onError: (message) => {
169
+ // A watcher that fails every tick would otherwise say so every minute.
170
+ if (message === reportedWatchError) return;
171
+ reportedWatchError = message;
172
+ ctx.ui.notify(`Teams listen mode: ${message}`, "warning");
173
+ },
174
+ });
175
+
176
+ watchLoop = loop;
177
+ setActiveWatchLoop(loop);
178
+ };
179
+
180
+ /**
181
+ * Paint the footer status line from the current connection.
182
+ *
183
+ * The only place the status line is written, so the three surfaces —
184
+ * session start, `/teams-status`, and a tool result — cannot drift apart.
185
+ */
186
+ const paintStatus = (ctx: any) => {
187
+ const card = buildConnectionCard(refresh());
188
+ if (!card) return;
189
+ const watching = !!watchLoop?.status().running;
190
+ ctx.ui.setStatus(
191
+ "teams",
192
+ ctx.ui.theme.fg(
193
+ card.signedIn ? "success" : "warning",
194
+ buildConnectionLabel({ ...card, watching }),
195
+ ),
196
+ );
197
+ };
198
+
121
199
  // -----------------------------------------------------------------------
122
200
  // Status card — rendered in the transcript, never sent to the model
123
201
  // -----------------------------------------------------------------------
@@ -162,7 +240,7 @@ export default function (pi: ExtensionAPI) {
162
240
  return;
163
241
  }
164
242
  pi.appendEntry<ConnectionCard>("teams-connection", card);
165
- ctx.ui.setStatus("teams", ctx.ui.theme.fg("success", buildConnectionLabel(card)));
243
+ paintStatus(ctx);
166
244
  ctx.ui.notify(buildConnectionLabel(card), "info");
167
245
  },
168
246
  });
@@ -188,6 +266,56 @@ export default function (pi: ExtensionAPI) {
188
266
  },
189
267
  });
190
268
 
269
+ pi.registerCommand("teams-listen", {
270
+ description: "Let pi listen to Teams: on, off, or status (default)",
271
+ handler: async (args, ctx) => {
272
+ const conn = refresh();
273
+ if (!conn) {
274
+ ctx.ui.notify(
275
+ `Teams: no account configured yet. Use the teams_setup tool, or edit ${getConfigPath()}.`,
276
+ "warning",
277
+ );
278
+ return;
279
+ }
280
+
281
+ const action = args.trim().toLowerCase() || "status";
282
+
283
+ if (action === "on" || action === "off") {
284
+ // Written to the account, not globally: the watcher listens as one
285
+ // identity, and "which account" must not be a guess.
286
+ const watch = setWatchConfig({ enabled: action === "on" }, conn.account);
287
+ startWatch(ctx);
288
+ paintStatus(ctx);
289
+ ctx.ui.notify(
290
+ watch.enabled
291
+ ? `Teams listen mode on — polling every ${watch.intervalSeconds} s, max ${watch.maxTriggersPerHour} wakes/hour.`
292
+ : "Teams listen mode off.",
293
+ "info",
294
+ );
295
+ return;
296
+ }
297
+
298
+ if (action !== "status") {
299
+ ctx.ui.notify(`Unknown argument "${action}". Use on, off, or status.`, "warning");
300
+ return;
301
+ }
302
+
303
+ const runtime = watchLoop?.status();
304
+ pi.appendEntry<ConnectionCard>("teams-connection", { ...buildConnectionCard(conn)!, watching: !!runtime?.running });
305
+ ctx.ui.notify(
306
+ [
307
+ `Listen mode: ${conn.watch.enabled ? "on" : "off"}`,
308
+ `every ${conn.watch.intervalSeconds} s`,
309
+ conn.watch.chats.length > 0 ? `chats: ${conn.watch.chats.join(", ")}` : "all recent chats",
310
+ conn.watch.from.length > 0 ? `people: ${conn.watch.from.join(", ")}` : "any sender",
311
+ conn.watch.mentionOnly ? "mentions only" : "every message",
312
+ runtime ? `${runtime.wakesThisHour} wake(s) this hour` : "not running in this session",
313
+ ].join(" · "),
314
+ "info",
315
+ );
316
+ },
317
+ });
318
+
191
319
  pi.registerCommand("teams-inbox", {
192
320
  description: "Show what needs your attention in Microsoft Teams",
193
321
  handler: async (_args, ctx) => {
@@ -246,7 +374,8 @@ export default function (pi: ExtensionAPI) {
246
374
  const card = buildConnectionCard(connection);
247
375
  if (!card) return;
248
376
 
249
- ctx.ui.setStatus("teams", ctx.ui.theme.fg(card.signedIn ? "success" : "warning", buildConnectionLabel(card)));
377
+ paintStatus(ctx);
378
+ startWatch(ctx);
250
379
 
251
380
  if (!card.signedIn) {
252
381
  ctx.ui.notify(
@@ -294,6 +423,16 @@ export default function (pi: ExtensionAPI) {
294
423
  });
295
424
  }
296
425
 
426
+ // -----------------------------------------------------------------------
427
+ // Listen mode lifecycle
428
+ // -----------------------------------------------------------------------
429
+
430
+ pi.on("session_shutdown", () => {
431
+ // The timer would be unref'd anyway, but a poll in flight during shutdown
432
+ // would still be a Graph call for a session that is gone.
433
+ stopWatch();
434
+ });
435
+
297
436
  // -----------------------------------------------------------------------
298
437
  // Safety interceptor
299
438
  // -----------------------------------------------------------------------
@@ -333,4 +472,39 @@ export default function (pi: ExtensionAPI) {
333
472
  if (!approved) return { block: true, reason: `User declined: ${summary}` };
334
473
  }
335
474
  });
475
+
476
+ // -----------------------------------------------------------------------
477
+ // Status after a tool ran
478
+ // -----------------------------------------------------------------------
479
+
480
+ /**
481
+ * The status line is a snapshot of session start — but every change to the
482
+ * connection happens afterwards, in a tool, and tools have no UI context of
483
+ * their own. Without this the footer would still read "not signed in" after a
484
+ * successful login, and "listening" after listen mode was switched off.
485
+ */
486
+ const STATUS_TOOLS = new Set([
487
+ "teams_login",
488
+ "teams_logout",
489
+ "teams_setup",
490
+ "teams_accounts",
491
+ "teams_watch",
492
+ ]);
493
+
494
+ /** Tools after which the watcher has to be re-read from the config. */
495
+ const WATCH_CONFIG_TOOLS = new Set(["teams_watch", "teams_setup", "teams_accounts"]);
496
+
497
+ pi.on("tool_result", async (event, ctx) => {
498
+ if (!STATUS_TOOLS.has(event.toolName) || event.isError) return;
499
+
500
+ if (event.toolName === "teams_logout") {
501
+ // A watcher polling a session that was just removed only produces
502
+ // errors; leaving it running would be noise, not service.
503
+ stopWatch();
504
+ } else if (WATCH_CONFIG_TOOLS.has(event.toolName)) {
505
+ startWatch(ctx);
506
+ }
507
+
508
+ paintStatus(ctx);
509
+ });
336
510
  }
@@ -11,7 +11,7 @@ import type { TeamsConnection } from "../config/index.ts";
11
11
  import type { ChannelSummary, MessageLocation, MessageSummary, PersonRef } from "../types.ts";
12
12
  import { graphGetOptional, graphList, graphPost } from "./client.ts";
13
13
  import { mapMessage } from "./mappers.ts";
14
- import { textToHtml } from "../utils/formatting.ts";
14
+ import { markdownToTeamsHtml } from "../utils/richtext.ts";
15
15
 
16
16
  /* eslint-disable @typescript-eslint/no-explicit-any */
17
17
  type Raw = Record<string, any>;
@@ -21,9 +21,15 @@ type Raw = Record<string, any>;
21
21
  // ---------------------------------------------------------------------------
22
22
 
23
23
  export interface MessageInput {
24
- /** Message text — plain text unless `html` is true */
24
+ /**
25
+ * Message text.
26
+ *
27
+ * Treated as lightweight markdown by default — bold, italic, code, bullets,
28
+ * numbered lists, links and `# headings` are converted to the HTML Teams
29
+ * renders. Set `html` to true to send raw HTML instead.
30
+ */
25
31
  body: string;
26
- /** Treat `body` as raw HTML instead of escaping it */
32
+ /** Treat `body` as raw HTML instead of converting markdown */
27
33
  html?: boolean;
28
34
  /** Channel messages only */
29
35
  subject?: string;
@@ -39,10 +45,14 @@ export interface MessageInput {
39
45
  * Mentions are the fiddly part: Teams requires an `<at id="N">` span in the
40
46
  * HTML *and* a matching entry in the `mentions` array. The display name is
41
47
  * replaced in the text so the caller can just write "@Anna Schmidt".
48
+ *
49
+ * Markdown is converted unless the caller says the body is already HTML: what
50
+ * comes back from a model is markdown, and markdown sent raw arrives as
51
+ * asterisks and dashes.
42
52
  */
43
53
  export function buildMessageBody(input: MessageInput): Record<string, unknown> {
44
54
  const mentions = input.mentions ?? [];
45
- let content = input.html ? input.body : textToHtml(input.body);
55
+ let content = input.html ? input.body : markdownToTeamsHtml(input.body);
46
56
 
47
57
  const mentionEntries: Record<string, unknown>[] = [];
48
58
  mentions.forEach((person, index) => {
@@ -39,7 +39,7 @@ export function isMutationTool(toolName: string): boolean {
39
39
  * needs a way to add an account or sign out. They also work before anything is
40
40
  * configured at all, which is the whole point of `teams_setup`.
41
41
  */
42
- export const LOCAL_CONFIG_TOOLS = new Set<string>(["teams_setup", "teams_logout"]);
42
+ export const LOCAL_CONFIG_TOOLS = new Set<string>(["teams_setup", "teams_logout", "teams_watch"]);
43
43
 
44
44
  /**
45
45
  * Why a mutation must be refused outright, or undefined when it may proceed
package/src/status.ts CHANGED
@@ -27,6 +27,10 @@ export interface ConnectionCard {
27
27
  otherAccounts: string[];
28
28
  /** Rendered scope rules */
29
29
  permissionSummary: string;
30
+ /** Listen mode is switched on */
31
+ watching?: boolean;
32
+ /** One line on what the watcher is doing, when it runs */
33
+ watchSummary?: string;
30
34
  }
31
35
 
32
36
  export function buildConnectionCard(
@@ -50,6 +54,19 @@ export function buildConnectionCard(
50
54
  expiresAt: cache.expiresAt ? new Date(cache.expiresAt).toISOString() : undefined,
51
55
  otherAccounts: conn.allAccounts.map((a) => a.name).filter((name) => name !== conn.account),
52
56
  permissionSummary: formatPermissions(conn.permissions),
57
+ // What the configuration asks for; a caller that knows whether the watcher
58
+ // is actually running overrides this with the runtime truth.
59
+ watching: conn.watch.enabled,
60
+ watchSummary: conn.watch.enabled
61
+ ? [
62
+ `every ${conn.watch.intervalSeconds} s`,
63
+ conn.watch.chats.length > 0 ? conn.watch.chats.join(", ") : "all recent chats",
64
+ conn.watch.from.length > 0 ? `from ${conn.watch.from.join(", ")}` : undefined,
65
+ `max ${conn.watch.maxTriggersPerHour}/h`,
66
+ ]
67
+ .filter(Boolean)
68
+ .join(" · ")
69
+ : undefined,
53
70
  };
54
71
  }
55
72
 
@@ -59,7 +76,8 @@ export function buildConnectionLabel(card: ConnectionCard | undefined): string {
59
76
  if (!card.signedIn) return `Teams · ${card.account} · not signed in`;
60
77
 
61
78
  const scope = card.tenant === card.account ? card.account : `${card.account}/${card.tenant}`;
62
- return `✓ Teams · ${scope}${card.user ? ` · ${card.user}` : ""}`;
79
+ const listening = card.watching ? " · 👂 listening" : "";
80
+ return `✓ Teams · ${scope}${card.user ? ` · ${card.user}` : ""}${listening}`;
63
81
  }
64
82
 
65
83
  /** Rich markdown for `/teams-status` and the expanded card. */
@@ -98,6 +116,9 @@ export function formatStatusText(card: ConnectionCard | undefined): string {
98
116
  if (card.otherAccounts.length > 0) {
99
117
  lines.push(`- **Other accounts:** ${card.otherAccounts.join(", ")}`);
100
118
  }
119
+ lines.push(
120
+ `- **Listen mode:** ${card.watching ? `on — ${card.watchSummary ?? "running"}` : "off"}`,
121
+ );
101
122
 
102
123
  lines.push("", "### What pi may do", "", card.permissionSummary);
103
124
  return lines.join("\n");
@@ -57,14 +57,17 @@ async function resolveMentions(
57
57
  const sharedParams = {
58
58
  channel: Type.String({ description: "Channel name or ID, or a 'Team/Channel' path" }),
59
59
  team: Type.Optional(Type.String({ description: "Team name or ID (omit when using a path)" })),
60
- body: Type.String({ description: "Message text. Plain text unless 'html' is true." }),
60
+ body: Type.String({
61
+ description:
62
+ "Message text as lightweight markdown (bold, italics, code, bullets, numbered lists, links)",
63
+ }),
61
64
  account: AccountParam,
62
65
  tenant: TenantParam,
63
66
  mentions: Type.Optional(
64
67
  Type.Array(Type.String(), { description: "People to @-mention, by name, UPN or e-mail" }),
65
68
  ),
66
69
  importance: Type.Optional(Type.String({ description: "'normal' (default), 'high' or 'urgent'" })),
67
- html: Type.Optional(Type.Boolean({ description: "Treat 'body' as raw HTML instead of plain text" })),
70
+ html: Type.Optional(Type.Boolean({ description: "Send 'body' as raw HTML instead of converting markdown" })),
68
71
  };
69
72
 
70
73
  export const teamsSendChannelMessageTool = {
@@ -81,6 +84,8 @@ export const teamsSendChannelMessageTool = {
81
84
  promptGuidelines: [
82
85
  "Write in the user's voice — the post is attributed to them, not to an assistant.",
83
86
  "Show the exact text and the target channel before posting when there is any ambiguity.",
87
+ "Format for a chat, not for a document: lead with the answer, three short paragraphs at most, bullets for lists. Bold, italics, code, links and bullets are rendered; headings become bold lines and tables are not supported — keep those out.",
88
+ "Answer in the language of the conversation you are writing into, and match its register.",
84
89
  ],
85
90
 
86
91
  async execute(
@@ -46,7 +46,10 @@ export const teamsSendChatMessageTool = {
46
46
  "where the mention should appear.",
47
47
  parameters: Type.Object({
48
48
  chat: Type.String({ description: "Chat ID, group chat topic, or a participant's name/e-mail" }),
49
- body: Type.String({ description: "Message text. Plain text unless 'html' is true." }),
49
+ body: Type.String({
50
+ description:
51
+ "Message text as lightweight markdown (bold, italics, code, bullets, numbered lists, links)",
52
+ }),
50
53
  account: AccountParam,
51
54
  tenant: TenantParam,
52
55
  mentions: Type.Optional(
@@ -58,7 +61,7 @@ export const teamsSendChatMessageTool = {
58
61
  Type.String({ description: "'normal' (default), 'high' or 'urgent'" }),
59
62
  ),
60
63
  html: Type.Optional(
61
- Type.Boolean({ description: "Treat 'body' as raw HTML instead of plain text" }),
64
+ Type.Boolean({ description: "Send 'body' as raw HTML instead of converting markdown" }),
62
65
  ),
63
66
  }),
64
67
  promptSnippet: "Send a message in a Teams chat as the user",
@@ -66,6 +69,8 @@ export const teamsSendChatMessageTool = {
66
69
  "Write the message in the user's voice — it is sent from their account, not from an assistant.",
67
70
  "Show the user the exact text before sending when the intent is even slightly ambiguous.",
68
71
  "Do not add signatures, disclaimers, or a note that the message was written by an AI unless the user asks.",
72
+ "Format for a chat, not for a document: lead with the answer, three short paragraphs at most, bullets for lists. Bold, italics, code, links and bullets are rendered; headings become bold lines and tables are not supported — keep those out.",
73
+ "Answer in the language of the conversation you are writing into, and match its register.",
69
74
  ],
70
75
 
71
76
  async execute(