@patimweb/pi-teams 1.1.0 → 1.3.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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: teams-collaboration
3
- description: Microsoft Teams chats, channels, meetings and presence, acting as the signed-in user. Use when the user asks to read, search, summarize, send or reply to Teams messages; to catch up on what they missed; to post in a channel or a thread; to start a chat; to react to a message; to check or set their Teams status; to see who is available; or to schedule, change or cancel a Teams meeting. Also covers multiple Teams accounts and multiple company tenants, and the allow/deny rules that decide what pi may touch.
3
+ description: Microsoft Teams chats, channels, meetings and presence, acting as the signed-in user. Use when the user asks to read, search, summarize, send, edit, retract or reply to Teams messages; to add or remove someone from a chat; to catch up on what they missed and clear it; to post in a channel or a thread; to react to a message; to mark a chat read or unread; to check or set their Teams status; to see who is available; to find a time everyone is free; to schedule, change, answer, or cancel a Teams meeting; or to make pi notice incoming Teams messages on its own (listen mode). Also covers how to format a message for a chat rather than a document, multiple Teams accounts and multiple company tenants, and the allow/deny rules that decide what pi may touch.
4
4
  ---
5
5
 
6
6
  # Microsoft Teams
@@ -47,6 +47,20 @@ rule set. Show them the rule, do not work around it, and do not try a different
47
47
  tool to achieve the same thing. `teams_permissions` with `action: "test"`
48
48
  checks a target without touching it.
49
49
 
50
+ ## When a tool says a scope is missing
51
+
52
+ One optional action needs a permission the account does not request by default:
53
+ removing someone from a **chat** (`ChatMember.ReadWrite`). The tool names the
54
+ exact scope. Pass that sentence on as the fix — add it to `scopes` in
55
+ `pi-teams.json`, sign in again — or do it in Teams. Do not look for a roundabout
56
+ way to achieve the same thing, and do not report it as a permission bug.
57
+
58
+ A few things cannot be done at all with the permissions pi asks for: creating a
59
+ channel, and editing or deleting a **channel** post. Those need
60
+ `Channel.Create` / `ChannelMessage.ReadWrite`, they are not requested, and they
61
+ are not assumed to be grantable. Say so plainly and point at the Teams client
62
+ instead of trying anyway.
63
+
50
64
  ## Tools
51
65
 
52
66
  ### Session
@@ -76,6 +90,7 @@ checks a target without touching it.
76
90
  | `teams_find_user` | Resolving a first name to a real person |
77
91
  | `teams_list_files` | Files shared in a channel |
78
92
  | `teams_get_presence` | Is someone available right now |
93
+ | `teams_availability` | "When are we all free?" — before booking, not after |
79
94
  | `teams_list_meetings` / `teams_get_meeting` | Calendar and meeting details |
80
95
 
81
96
  ### Writing
@@ -87,10 +102,14 @@ checks a target without touching it.
87
102
  | `teams_send_channel_message` | Posting a new channel message |
88
103
  | `teams_reply_channel_message` | Answering inside an existing thread |
89
104
  | `teams_react` | Adding or removing an emoji reaction |
105
+ | `teams_update_message` | Fixing a message the user already sent |
106
+ | `teams_chat_members` | Bringing someone into a group chat, or taking them out |
107
+ | `teams_mark_read` | Clearing the unread marker after a catch-up |
90
108
  | `teams_delete_message` | Taking back something the user sent |
91
- | `teams_create_channel` | Adding a channel to a team |
92
109
  | `teams_set_presence` / `teams_set_status_message` | Changing the user's status |
93
110
  | `teams_create_meeting` / `teams_update_meeting` / `teams_cancel_meeting` | Scheduling work |
111
+ | `teams_respond_invite` | Accepting, declining or tentatively answering an invitation |
112
+ | `teams_watch` | Letting pi notice incoming messages on its own |
94
113
 
95
114
  ## How to write as the user
96
115
 
@@ -105,6 +124,48 @@ checks a target without touching it.
105
124
  - Prefer `teams_reply_channel_message` over a new post when answering something
106
125
  — starting a second thread fragments the conversation.
107
126
 
127
+ ## How to format a Teams message
128
+
129
+ A Teams chat is a chat bubble, not a document. What arrives badly is long
130
+ prose: the reader has to hunt for the point, and it reads as if a machine wrote
131
+ it. The body is rendered as HTML — markdown is converted for you, so write
132
+ markdown and let the tool do the rest.
133
+
134
+ **Reach for this shape, not for paragraphs:**
135
+
136
+ ```
137
+ Kurz: die QA-Umgebung läuft wieder.
138
+
139
+ - Ursache: abgelaufenes Zertifikat
140
+ - Fix: erneuert und neu deployt
141
+ - Offen: Monitoring für 90 Tage
142
+ ```
143
+
144
+ - **Lead with the answer.** The first line should work as the whole message for
145
+ someone who reads nothing else.
146
+ - **Three short paragraphs at most.** If it needs more, it is a document — put
147
+ it in a file or a wiki page and link it.
148
+ - **Bullets for anything enumerable** — causes, options, next steps. Three items
149
+ and up belong in a list, not in a sentence with commas.
150
+ - **Bold the one thing that must not be missed**, and only that. Bold used
151
+ everywhere is bold nowhere.
152
+ - **Numbers, names and dates exactly as they are.** Do not round a figure or
153
+ paraphrase a deadline into something friendlier.
154
+ - **One question per message.** Two questions get one answer.
155
+
156
+ **What Teams renders:** `**bold**`, `*italic*`, `` `code` ``, `~~struck~~`,
157
+ `[label](url)`, `- bullets`, `1. numbered`, ``` fences. A `# heading` arrives as
158
+ a bold line — fine for structure, not for hierarchy. **Tables are not supported
159
+ in a chat**: convert them to bullets, or put the table in a file and link it.
160
+
161
+ **Language and tone:** answer in the language of the conversation, in the
162
+ register of the people in it. Short forms are normal in a chat ("passt", "ok,
163
+ 5 min") — but never shorten a fact into ambiguity.
164
+
165
+ When pi listens on its own (see *Listen mode*), this matters most: an answer
166
+ that arrives unrequested must be readable at a glance, because the recipient is
167
+ not expecting it.
168
+
108
169
  ## Channel addressing
109
170
 
110
171
  Channels are addressed as `Team/Channel` or via separate `team` and `channel`
@@ -143,3 +204,117 @@ summarize from openers alone.
143
204
  Before replying anywhere, read the recent messages first. It prevents
144
205
  answering a question that was already answered, and it is what tells you the
145
206
  tone to match.
207
+
208
+ ## Read state
209
+
210
+ `teams_mark_read` moves the user's read cursor on a **chat** — the same thing
211
+ as opening it, or re-marking it unread, in the app. Two rules:
212
+
213
+ - It is a **write**, and it goes through the safety gates like any other. Mark
214
+ a chat read when the user asks, or when they clearly want the list cleared —
215
+ not on your own initiative after reading it.
216
+ - Where the tenant shows **read receipts**, the sender can see that the chat was
217
+ opened. That is not a reason to refuse, only a reason not to do it silently.
218
+
219
+ Channels have no equivalent: channel posts cannot be marked read or unread
220
+ through Graph, so "mark the channel as read" is done in the Teams client.
221
+
222
+ ## Correcting a message
223
+
224
+ Both actions work on **chat** messages the user themselves sent, which is also
225
+ the only case that makes sense: pi wrote them in the user's name. A channel post
226
+ cannot be edited or deleted with the permissions pi asks for — for that, point
227
+ at the Teams client.
228
+
229
+ - **`teams_update_message` replaces the text and keeps the message in place.**
230
+ Teams shows it as edited in the same bubble. This is the right answer to a
231
+ typo, a wrong time, a missing detail.
232
+ - **`teams_delete_message` takes it back.** Right when the message should not
233
+ exist at all: wrong chat, wrong content, sent too early.
234
+
235
+ Never correct by deleting and resending. The replacement arrives as a brand-new
236
+ notification, everyone who already read the first message has to notice the gap,
237
+ and the thread loses its order. Editing is the polite version.
238
+
239
+ An edit sends the text only: mentions and attachments from the original are not
240
+ re-created. That is deliberate — re-sending mention entities would ping people a
241
+ second time for a message they were already notified about.
242
+
243
+ ## Chat membership
244
+
245
+ `teams_chat_members` adds a person to a group chat or removes one. Adding
246
+ someone hands them the conversation, so:
247
+
248
+ - Resolve the person first (`teams_find_user`) when only a first name was given,
249
+ and say the full name in your reply. "Tom" is not an identity.
250
+ - A **1:1** chat cannot be extended — Graph refuses. Use `teams_create_chat`
251
+ with everyone who belongs in it.
252
+ - Removing someone needs an extra permission (`ChatMember.ReadWrite`); the tool
253
+ says so. Adding works with the default scopes.
254
+
255
+ ## Finding a time before booking one
256
+
257
+ `teams_availability` reads **free/busy**, never calendars: "is Tuesday at two
258
+ possible" is answerable, "what is Anna doing on Tuesday" is not, and must never
259
+ be presented as if it were.
260
+
261
+ 1. Run it with everybody who has to be there and the length of the meeting.
262
+ 2. Offer the user two or three of the slots it returns — not all of them.
263
+ 3. Only once they pick one, book it with `teams_create_meeting`.
264
+
265
+ Never book a slot because it looked free, and never dress the busy blocks up as
266
+ anything more than times: knowing that somebody is busy is the point, knowing why
267
+ is their business.
268
+
269
+ ## Answering an invitation
270
+
271
+ `teams_respond_invite` accepts, declines or marks an invitation as tentative,
272
+ and tells the organizer unless `sendResponse: false`. This fills the user's
273
+ calendar and answers another person, so it needs their decision — "say yes to
274
+ whatever looks relevant" is not one.
275
+
276
+ A meeting the user **organizes** has no invitation to answer: change it with
277
+ `teams_update_meeting`, cancel it with `teams_cancel_meeting`.
278
+
279
+ ## Listen mode
280
+
281
+ Listen mode is the only case where pi acts without being asked: it polls the
282
+ user's chats, and an incoming message is turned into a prompt. Off by default,
283
+ and it costs one model turn per message that wakes it.
284
+
285
+ ```yaml
286
+ teams_watch:
287
+ action: enable
288
+ chats: ["Anna*", "Vertrieb*"] # where to listen
289
+ from: ["anna.schmidt@contoso.com"] # who to listen to
290
+ mentionOnly: false
291
+ intervalSeconds: 60
292
+ ```
293
+
294
+ - `chats` decides **where** pi listens (topic, label, chat ID, participants),
295
+ `from` decides **to whom** (name, UPN, e-mail). Narrow both: a watcher that
296
+ watches everything answers everything.
297
+ - `/teams-listen on|off|status` switches it in the session; `teams_watch
298
+ action: status` reports what is configured and what is actually running.
299
+ - The first poll only records what is there — switching it on never answers the
300
+ backlog, only what arrives afterwards.
301
+ - pi never wakes for its own messages, so it cannot answer itself.
302
+ - Listen mode does **not** bypass anything: a reply it decides to send is
303
+ subject to the same safety level and scope rules as a reply you asked for. At
304
+ `safetyLevel: confirm` every one of them is confirmed with the user first.
305
+
306
+ **When a listen prompt arrives**, do this:
307
+
308
+ 1. Judge the message before answering. "Danke, passt" needs no reply; a
309
+ question, a blocker or a decision does.
310
+ 2. If an answer is warranted, send it with `teams_send_chat_message` to the
311
+ **chat ID from the prompt** — never re-resolve the chat by name. pi posts as
312
+ the user, so the wrong chat is a wrong statement in the user's name.
313
+ 3. Keep it to the shape in *How to format a Teams message*: answer first, three
314
+ short paragraphs at most, bullets for lists. An unrequested message has to be
315
+ readable at a glance.
316
+ 4. If no answer is warranted, say so in one line and stop. Do not react, do not
317
+ touch other chats, do not post to a channel.
318
+
319
+ Never let listen mode turn into a conversation with itself: if the incoming
320
+ message is already an answer to something pi sent, the loop ends there.
@@ -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,