@patimweb/pi-teams 1.3.0 → 1.4.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.
package/README.md CHANGED
@@ -82,8 +82,12 @@ What it does, and what it deliberately does not:
82
82
  - **Costs a model turn per wake.** That is what the filters and the hourly cap
83
83
  are for — narrow `chats` and `from`, and enable it for a handful of
84
84
  conversations rather than the whole company.
85
- - **Never answers the backlog.** The first poll records what is there; only what
86
- arrives afterwards wakes pi.
85
+ - **Answers what is still unread.** A chat wakes pi when two things are true: it
86
+ moved since pi last looked (a cursor per account, kept on disk), and its newest
87
+ message is still unread for you in Teams. So switching listen mode back on
88
+ answers what you missed while pi was not running — and a chat you already read
89
+ in Teams stays quiet. A backlog is drained a few chats per tick and capped by
90
+ `maxTriggersPerHour`, so a long absence does not produce a burst.
87
91
  - **Never wakes for its own messages.** pi posts as you, so its own reply comes
88
92
  back as "my own message" and stops the loop.
89
93
  - **Respects the read rules.** A chat excluded by `permissions.read.chats` is
@@ -352,6 +356,37 @@ teams_send_channel_message:
352
356
  Set globally, per account, or per tenant — the most specific setting wins. So
353
357
  your own company can be `confirm` while a customer tenant stays `readonly`.
354
358
 
359
+ ### AI disclosure footer
360
+
361
+ Off by default. Switched on, every message, reply and edit pi sends in your name
362
+ ends with a short note saying it was written by an AI:
363
+
364
+ ```json
365
+ "aiFooter": { "enabled": true, "text": "🤖 Erstellt mit pi (KI-Assistent)" }
366
+ ```
367
+
368
+ | Key | Default | Meaning |
369
+ |-----|---------|---------|
370
+ | `enabled` | `false` | append the note to everything pi sends |
371
+ | `text` | `🤖 Generated with pi (an AI agent)` | the wording; empty means the default |
372
+
373
+ Set it globally or per account (the account level wins field by field, so one
374
+ account can use its own wording while another keeps the global one).
375
+
376
+ Details worth knowing:
377
+
378
+ - It is added by the tool that sends, not asked for in a prompt, so it cannot be
379
+ forgotten by the model. The confirmation dialog at `safetyLevel: confirm`
380
+ shows the text **with** the footer — you confirm what actually goes out.
381
+ - It is idempotent: editing an already-footered message does not stack a second
382
+ one, and a model that copied the footer from the chat it is answering does not
383
+ produce two.
384
+ - It applies to chat messages, channel posts and replies, the first message of a
385
+ newly created chat, and edits. It does **not** apply to things pi sends on
386
+ your behalf that are not messages — meeting invitations, presence changes.
387
+ - It says nothing about the *content*: the note is a disclosure, not a
388
+ disclaimer, and it is the only part of an outgoing message pi adds on its own.
389
+
355
390
  ### Scope rules
356
391
 
357
392
  Rules exist per **category** (`teams`, `channels`, `chats`, `people`) and per
@@ -392,6 +427,7 @@ teams_permissions:
392
427
  | `maxMessages` | `25` | default page size for message listings |
393
428
  | `audit` | `true` | append every write to `~/.pi/agent/pi-teams-audit.jsonl` |
394
429
  | `scopes` | see above | override the requested Graph scopes per account/tenant |
430
+ | `aiFooter` | off | append an AI disclosure to every message pi sends — see above |
395
431
  | `graphBaseUrl` / `authorityHost` | Microsoft public cloud | sovereign cloud endpoints |
396
432
 
397
433
  ---
@@ -548,11 +584,15 @@ teams_send_channel_message:
548
584
  | `~/.pi/agent/pi-teams.json` | accounts, tenants, safety levels, scope rules | `0600` |
549
585
  | `~/.pi/agent/pi-teams-tokens/` | one MSAL token cache per account+tenant | `0600` |
550
586
  | `~/.pi/agent/pi-teams-audit.jsonl` | one line per write: when, who, where, what | `0600` |
587
+ | `~/.pi/agent/pi-teams-watch/` | listen mode's cursor: the newest message it has looked at per chat | `0600` |
551
588
 
552
589
  Both the config and the token cache are written through a private temp file and
553
- an atomic rename, so an interrupted write cannot truncate them. To revoke
554
- pi's access entirely, run `teams_logout` with `all: true` and remove the app's
555
- consent in Entra ID.
590
+ an atomic rename, so an interrupted write cannot truncate them. The watch
591
+ cursor is what makes listen mode survive a restart, and deleting it only costs
592
+ one re-examination of the chats in the list — it is not silently re-answering
593
+ anything, because the decision also asks Teams whether the message is still
594
+ unread. To revoke pi's access entirely, run `teams_logout` with `all: true` and
595
+ remove the app's consent in Entra ID.
556
596
 
557
597
  ## Architecture
558
598
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-teams",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Microsoft Teams integration for pi coding agent — acts fully as the signed-in user across multiple accounts and tenants: chats, channels, meetings, presence, with configurable guardrails",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -114,7 +114,11 @@ instead of trying anyway.
114
114
  ## How to write as the user
115
115
 
116
116
  - Write in **their** voice. No "As an AI…", no signature, no note that a model
117
- composed it, unless they ask for one.
117
+ composed it, unless they ask for one. The one exception is configuration, not
118
+ judgement: with `aiFooter` switched on, the sending tools append the
119
+ disclosure themselves — do not write it into the body, or it arrives twice
120
+ (the tools detect it, but the user sees the text in the confirmation twice as
121
+ well, which looks like a mistake).
118
122
  - Match the register of the conversation you just read. A channel post and a
119
123
  quick reply to a colleague are not the same thing.
120
124
  - Show the exact text and the destination before sending whenever the request
@@ -219,6 +223,10 @@ as opening it, or re-marking it unread, in the app. Two rules:
219
223
  Channels have no equivalent: channel posts cannot be marked read or unread
220
224
  through Graph, so "mark the channel as read" is done in the Teams client.
221
225
 
226
+ pi reads this same cursor when it decides what to answer in listen mode: a chat
227
+ whose newest message is already read does not wake it. So marking a chat read is
228
+ also how the user says "I have handled this, do not answer it for me".
229
+
222
230
  ## Correcting a message
223
231
 
224
232
  Both actions work on **chat** messages the user themselves sent, which is also
@@ -296,8 +304,13 @@ teams_watch:
296
304
  watches everything answers everything.
297
305
  - `/teams-listen on|off|status` switches it in the session; `teams_watch
298
306
  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.
307
+ - pi answers what is still **unread**: a chat wakes it when it has moved since
308
+ pi last looked (a cursor kept on disk per account) and its newest message is
309
+ still unread for the user in Teams. Switching listen mode back on therefore
310
+ answers what was missed while pi was not running; a chat the user has already
311
+ read in Teams stays quiet. A backlog is drained a few chats per tick, capped
312
+ by `maxTriggersPerHour` — so on a fresh start, say how many chats are waiting
313
+ rather than assuming silence means nothing happened.
301
314
  - pi never wakes for its own messages, so it cannot answer itself.
302
315
  - Listen mode does **not** bypass anything: a reply it decides to send is
303
316
  subject to the same safety level and scope rules as a reply you asked for. At
@@ -134,6 +134,27 @@ export interface ResolvedWatchConfig {
134
134
  maxTriggersPerHour: number;
135
135
  }
136
136
 
137
+ /**
138
+ * The note pi can append to everything it sends in the user's name.
139
+ *
140
+ * Off by default, and deliberately so: pi writes as the user, and a disclosure
141
+ * a recipient did not expect is a statement the user did not make. Where an
142
+ * organisation wants it — a works council, a customer, a regulated process —
143
+ * switching it on is a one-line change.
144
+ */
145
+ export interface AiFooterConfig {
146
+ /** Append the note to every outgoing message (default: false) */
147
+ enabled?: boolean;
148
+ /** The note itself (default: see AI_FOOTER_DEFAULT) */
149
+ text?: string;
150
+ }
151
+
152
+ /** An AI footer after the cascade has been applied. */
153
+ export interface ResolvedAiFooter {
154
+ enabled: boolean;
155
+ text: string;
156
+ }
157
+
137
158
  /** A Teams identity pi can sign in as. */
138
159
  export interface AccountConfig {
139
160
  /** Short label used in tool parameters, e.g. "work" */
@@ -164,6 +185,8 @@ export interface AccountConfig {
164
185
  permissions?: PermissionBlock;
165
186
  /** Listen-mode overrides for this identity */
166
187
  watch?: WatchConfig;
188
+ /** AI-footer override for this identity */
189
+ aiFooter?: AiFooterConfig;
167
190
  /** Additional tenants this identity reaches */
168
191
  tenants?: TenantConfig[];
169
192
  }
@@ -181,6 +204,8 @@ export interface TeamsRootConfig {
181
204
  permissions?: PermissionBlock;
182
205
  /** Global listen-mode settings */
183
206
  watch?: WatchConfig;
207
+ /** Append an AI note to every outgoing message (default: off) */
208
+ aiFooter?: AiFooterConfig;
184
209
  /** Default page size for message listings (default: 25) */
185
210
  maxMessages?: number;
186
211
  /** Append every write to ~/.pi/agent/pi-teams-audit.jsonl (default: true) */
@@ -212,6 +237,8 @@ export interface TeamsConnection {
212
237
  permissions: ResolvedPermissions;
213
238
  /** Listen-mode settings in force for this account+tenant */
214
239
  watch: ResolvedWatchConfig;
240
+ /** AI-footer settings in force for this account */
241
+ aiFooter: ResolvedAiFooter;
215
242
  maxMessages: number;
216
243
  audit: boolean;
217
244
  graphBaseUrl: string;
@@ -267,6 +294,7 @@ const DEFAULTS = {
267
294
  authMode: "auto" as AuthMode,
268
295
  maxMessages: 25,
269
296
  audit: true,
297
+ aiFooter: false,
270
298
  graphBaseUrl: "https://graph.microsoft.com/v1.0",
271
299
  authorityHost: "https://login.microsoftonline.com",
272
300
  };
@@ -288,6 +316,14 @@ export const WATCH_DEFAULTS: ResolvedWatchConfig = {
288
316
  maxTriggersPerHour: 10,
289
317
  };
290
318
 
319
+ /**
320
+ * The disclosure appended to outgoing messages when `aiFooter` is on.
321
+ *
322
+ * Short, unambiguous and in the language of the package; an organisation that
323
+ * needs its own wording sets `aiFooter.text`.
324
+ */
325
+ export const AI_FOOTER_DEFAULT = "🤖 Generated with pi (an AI agent)";
326
+
291
327
  const WATCH_BOUNDS = {
292
328
  intervalSeconds: { min: 15, max: 3600 },
293
329
  cooldownSeconds: { min: 0, max: 86400 },
@@ -529,6 +565,7 @@ export function resolveConnection(
529
565
  safetyLevel: resolveEffectiveSafetyLevel(config.safetyLevel, account, tenant),
530
566
  permissions: resolvePermissions([config.permissions, account.permissions, tenant?.permissions]),
531
567
  watch: resolveWatchConfig(config.watch, account),
568
+ aiFooter: resolveAiFooter(config.aiFooter, account.aiFooter),
532
569
  maxMessages: config.maxMessages ?? DEFAULTS.maxMessages,
533
570
  audit: config.audit ?? DEFAULTS.audit,
534
571
  graphBaseUrl: (config.graphBaseUrl ?? DEFAULTS.graphBaseUrl).replace(/\/+$/, ""),
@@ -696,6 +733,28 @@ export function resolveWatchConfig(
696
733
  };
697
734
  }
698
735
 
736
+ /**
737
+ * Effective AI-footer settings: account > global > default.
738
+ *
739
+ * Field by field, like every other setting in the cascade, so an account can
740
+ * turn the footer on with the global wording, or keep it off with its own.
741
+ * Exported for tests.
742
+ */
743
+ export function resolveAiFooter(
744
+ global: AiFooterConfig | undefined,
745
+ account: AiFooterConfig | undefined,
746
+ ): ResolvedAiFooter {
747
+ const text = account?.text ?? global?.text;
748
+ const trimmed = typeof text === "string" ? text.trim() : "";
749
+
750
+ return {
751
+ enabled: account?.enabled ?? global?.enabled ?? DEFAULTS.aiFooter,
752
+ // A footer configured as whitespace would silently switch the feature on
753
+ // and add nothing, so it falls back to the default wording instead.
754
+ text: trimmed.length > 0 ? trimmed : AI_FOOTER_DEFAULT,
755
+ };
756
+ }
757
+
699
758
  function clamp(value: unknown, bounds: { min: number; max: number }, fallback: number): number {
700
759
  const number = typeof value === "number" && Number.isFinite(value) ? value : fallback;
701
760
  return Math.min(bounds.max, Math.max(bounds.min, Math.round(number)));
@@ -841,7 +900,8 @@ const TEMPLATE_JSON = `{
841
900
  "defaultAccount": "work",
842
901
  "safetyLevel": "confirm",
843
902
  "maxMessages": 25,
844
- "audit": true
903
+ "audit": true,
904
+ "aiFooter": { "enabled": false, "text": "🤖 Generated with pi (an AI agent)" }
845
905
  }
846
906
  `;
847
907
 
@@ -30,6 +30,7 @@ import {
30
30
  type WatchLoop,
31
31
  } from "../watch/loop.ts";
32
32
  import { composeWatchPrompt } from "../watch/prompt.ts";
33
+ import { readWatchCursor, writeWatchCursor } from "../watch/cursor.ts";
33
34
 
34
35
  import { teamsSetupTool } from "../tools/teams-setup.ts";
35
36
  import { teamsLoginTool, teamsLogoutTool } from "../tools/teams-login.ts";
@@ -143,12 +144,23 @@ export default function (pi: ExtensionAPI) {
143
144
  /** The watcher running in this session, when listen mode is on. */
144
145
  let watchLoop: WatchLoop | undefined;
145
146
 
147
+ /**
148
+ * The settings the running watcher was started from.
149
+ *
150
+ * Used to tell a real config change from a tool that merely *read* the
151
+ * settings. A restart only costs the polls it skipped: what has already been
152
+ * looked at is on disk (see ../watch/cursor.ts), so the next tick resumes
153
+ * where the last one stopped instead of losing the interval in between.
154
+ */
155
+ let watchSignature: string | undefined;
156
+
146
157
  /** The last polling error the user was told about, so it is said once. */
147
158
  let reportedWatchError: string | undefined;
148
159
 
149
160
  const stopWatch = () => {
150
161
  watchLoop?.stop();
151
162
  watchLoop = undefined;
163
+ watchSignature = undefined;
152
164
  setActiveWatchLoop(undefined);
153
165
  };
154
166
 
@@ -159,14 +171,24 @@ export default function (pi: ExtensionAPI) {
159
171
  * change cannot leave two pollers running.
160
172
  */
161
173
  const startWatch = (ctx: any) => {
174
+ const conn = refresh();
175
+
176
+ // Already watching this exact configuration: leave the loop alone, so its
177
+ // cooldowns survive a look at the status.
178
+ const signature = conn?.watch.enabled ? JSON.stringify(conn.watch) : undefined;
179
+ if (watchSignature !== undefined && signature === watchSignature && watchLoop) return;
180
+
162
181
  stopWatch();
163
182
 
164
- const conn = refresh();
165
183
  if (!conn?.watch.enabled) return;
166
184
 
167
185
  reportedWatchError = undefined;
168
186
  const loop = startWatchLoop({
169
187
  connection: conn,
188
+ // Read once, here: the watch state belongs to the account+tenant, so a
189
+ // second account in the same session starts from its own cursor.
190
+ cursor: readWatchCursor(conn.account, conn.tenantId),
191
+ persistCursor: (seen) => writeWatchCursor(conn.account, conn.tenantId, seen),
170
192
  onWake: (event) => {
171
193
  pi.sendUserMessage(composeWatchPrompt(event, event.me), { deliverAs: "followUp" });
172
194
  },
@@ -182,6 +204,7 @@ export default function (pi: ExtensionAPI) {
182
204
  });
183
205
 
184
206
  watchLoop = loop;
207
+ watchSignature = signature;
185
208
  setActiveWatchLoop(loop);
186
209
  };
187
210
 
@@ -472,7 +495,7 @@ export default function (pi: ExtensionAPI) {
472
495
 
473
496
  if (target.safetyLevel === "confirm") {
474
497
  const scope = target.tenant === target.account ? target.account : `${target.account}/${target.tenant}`;
475
- const summary = formatMutationSummary(event.toolName, params);
498
+ const summary = formatMutationSummary(event.toolName, params, target.aiFooter);
476
499
  const approved = await ctx.ui.confirm(
477
500
  "Microsoft Teams — acting as you",
478
501
  `${summary}\n\nAccount: ${scope}\n\nAllow this?`,
@@ -70,6 +70,47 @@ export async function getChat(
70
70
  return raw ? mapChat(raw as Record<string, never>, options.meId) : undefined;
71
71
  }
72
72
 
73
+ /**
74
+ * The signed-in user's own view of one chat.
75
+ *
76
+ * Graph returns this as `viewpoint` on the chat itself, and only for the
77
+ * signed-in user — it is the read cursor Teams shows as the unread badge. It
78
+ * cannot be `$expand`ed on the chat *list* (Graph answers "not a navigation
79
+ * property"), so the read state of a chat costs one extra call on that chat.
80
+ *
81
+ * Listen mode is the reason this exists: "answer what I have not read yet" is
82
+ * a different question from "answer what arrived since pi last looked", and
83
+ * only this answers the first one.
84
+ */
85
+ export interface ChatViewpoint {
86
+ /** Chat hidden from the user's chat list */
87
+ isHidden?: boolean;
88
+ /** ISO time the user last read this chat; undefined when Graph withholds it */
89
+ lastMessageReadAt?: string;
90
+ }
91
+
92
+ export async function getChatViewpoint(
93
+ conn: TeamsConnection,
94
+ chatId: string,
95
+ options: { signal?: AbortSignal } = {},
96
+ ): Promise<ChatViewpoint | undefined> {
97
+ const raw = await graphGetOptional<Record<string, unknown>>(
98
+ conn,
99
+ `/chats/${encodeURIComponent(chatId)}`,
100
+ { signal: options.signal },
101
+ );
102
+
103
+ const viewpoint = raw?.viewpoint as Record<string, unknown> | undefined;
104
+ if (!viewpoint) return undefined;
105
+
106
+ return {
107
+ isHidden: viewpoint.isHidden === true,
108
+ lastMessageReadAt: typeof viewpoint.lastMessageReadDateTime === "string"
109
+ ? viewpoint.lastMessageReadDateTime
110
+ : undefined,
111
+ };
112
+ }
113
+
73
114
  /**
74
115
  * Find an existing chat by label, topic or participant.
75
116
  *
@@ -134,6 +134,25 @@ export function teamCandidates(team: TeamSummary): (string | undefined)[] {
134
134
  // Chats
135
135
  // ---------------------------------------------------------------------------
136
136
 
137
+ /**
138
+ * When did this chat last *move*?
139
+ *
140
+ * Deliberately the time of the last **message** rather than Graph's
141
+ * `lastUpdatedDateTime`. That field tracks chat metadata and is badly stale for
142
+ * 1:1 and ad-hoc group chats: measured against a live tenant, a chat whose
143
+ * newest message arrived minutes ago still reported a `lastUpdatedDateTime`
144
+ * from more than a year earlier.
145
+ *
146
+ * Everything that dates a chat depends on this — the chat list order, the inbox
147
+ * window, and the question listen mode asks first, "has this chat moved since pi
148
+ * last looked?" — so a stale value here silently disabled listen mode for
149
+ * exactly the chats people write in. `lastUpdatedDateTime` stays as the
150
+ * fallback for chats Graph returns without a preview.
151
+ */
152
+ function chatActivity(raw: Raw): string | undefined {
153
+ return raw.lastMessagePreview?.createdDateTime ?? raw.lastUpdatedDateTime ?? undefined;
154
+ }
155
+
137
156
  /**
138
157
  * Build a chat summary.
139
158
  *
@@ -162,7 +181,7 @@ export function mapChat(raw: Raw, meId?: string): ChatSummary {
162
181
  topic: raw.topic ?? undefined,
163
182
  label: raw.topic || derived || raw.id || "(chat)",
164
183
  members,
165
- lastUpdated: raw.lastUpdatedDateTime ?? raw.lastMessagePreview?.createdDateTime ?? undefined,
184
+ lastUpdated: chatActivity(raw),
166
185
  lastMessagePreview: preview,
167
186
  lastMessageFrom: mapPerson(raw.lastMessagePreview?.from)?.displayName,
168
187
  webUrl: raw.webUrl ?? undefined,
@@ -18,8 +18,9 @@
18
18
  * which channel a message is actually headed for.
19
19
  */
20
20
 
21
- import type { ResolvedAuthMode, SafetyLevel, TeamsConnection } from "../config/index.ts";
21
+ import type { ResolvedAiFooter, ResolvedAuthMode, SafetyLevel, TeamsConnection } from "../config/index.ts";
22
22
  import { checkScope, type ScopeCategory, type ScopeMode } from "../config/scope.ts";
23
+ import { applyAiFooter } from "../utils/disclosure.ts";
23
24
  import { ScopeDeniedError } from "../utils/errors.ts";
24
25
  import { MUTATION_TOOLS } from "../tools/tool-names.ts";
25
26
 
@@ -103,30 +104,39 @@ const APP_ONLY_FORBIDDEN = new Set<string>([
103
104
  *
104
105
  * This is the last thing a user reads before pi speaks for them, so it shows
105
106
  * the destination and the actual text — never just the tool name.
107
+ *
108
+ * The AI footer is applied here as well, for the same reason: a confirmation
109
+ * that shows the body without the disclosure would be a confirmation of a
110
+ * different message than the one that gets sent.
106
111
  */
107
112
  export function formatMutationSummary(
108
113
  toolName: string,
109
114
  params: Record<string, unknown>,
115
+ footer?: ResolvedAiFooter,
110
116
  ): string {
111
117
  const text = (value: unknown, max = 160): string => {
112
118
  const str = String(value ?? "").replace(/\s*\n\s*/g, " ").trim();
113
119
  return str.length > max ? `${str.slice(0, max - 1)}…` : str;
114
120
  };
121
+ const disclosed = (value: unknown): string => applyAiFooter(text(value, 400), footer);
115
122
 
116
123
  switch (toolName) {
117
124
  case "teams_send_chat_message":
118
- return `Send a chat message to "${text(params.chat, 60)}":\n\n${text(params.body, 400)}`;
125
+ return `Send a chat message to "${text(params.chat, 60)}":\n\n${disclosed(params.body)}`;
119
126
  case "teams_send_channel_message": {
120
127
  const where = params.team ? `${text(params.team, 40)}/${text(params.channel, 40)}` : text(params.channel, 60);
121
- return `Post in channel ${where}:\n\n${text(params.body, 400)}`;
128
+ return `Post in channel ${where}:\n\n${disclosed(params.body)}`;
122
129
  }
123
130
  case "teams_reply_channel_message": {
124
131
  const where = params.team ? `${text(params.team, 40)}/${text(params.channel, 40)}` : text(params.channel, 60);
125
- return `Reply in thread ${text(params.messageId, 40)} (${where}):\n\n${text(params.body, 400)}`;
132
+ return `Reply in thread ${text(params.messageId, 40)} (${where}):\n\n${disclosed(params.body)}`;
126
133
  }
127
134
  case "teams_create_chat": {
128
135
  const people = Array.isArray(params.participants) ? params.participants.join(", ") : text(params.participants);
129
- return `Create a new chat with: ${text(people, 200)}`;
136
+ const first = typeof params.message === "string" && params.message.trim()
137
+ ? `\n\nFirst message:\n${disclosed(params.message)}`
138
+ : "";
139
+ return `Create a new chat with: ${text(people, 200)}${first}`;
130
140
  }
131
141
  case "teams_delete_message":
132
142
  return `Delete your message ${text(params.messageId, 60)}`;
@@ -135,7 +145,7 @@ export function formatMutationSummary(
135
145
  case "teams_mark_read":
136
146
  return `${params.read === false ? "Mark as unread" : "Mark as read"}: chat "${text(params.chat, 60)}"`;
137
147
  case "teams_update_message":
138
- return `Edit your message ${text(params.messageId, 50)} in ${text(params.chat ?? params.channel, 60)}:\n\n${text(params.body, 400)}`;
148
+ return `Edit your message ${text(params.messageId, 50)} in ${text(params.chat ?? params.channel, 60)}:\n\n${disclosed(params.body)}`;
139
149
  case "teams_chat_members":
140
150
  return `${params.action === "remove" ? "Remove" : "Add"} ${text(params.person, 80)} ${params.action === "remove" ? "from" : "to"} the chat "${text(params.chat, 60)}"`;
141
151
  case "teams_respond_invite":
package/src/status.ts CHANGED
@@ -31,6 +31,8 @@ export interface ConnectionCard {
31
31
  watching?: boolean;
32
32
  /** One line on what the watcher is doing, when it runs */
33
33
  watchSummary?: string;
34
+ /** The AI disclosure in force, rendered as one line; undefined when off */
35
+ aiFooter?: string;
34
36
  }
35
37
 
36
38
  export function buildConnectionCard(
@@ -67,6 +69,9 @@ export function buildConnectionCard(
67
69
  .filter(Boolean)
68
70
  .join(" · ")
69
71
  : undefined,
72
+ // Off is the normal state and needs no explanation; on gets the wording,
73
+ // because that is what every recipient is about to read.
74
+ aiFooter: conn.aiFooter.enabled ? `on — “${conn.aiFooter.text}”` : undefined,
70
75
  };
71
76
  }
72
77
 
@@ -119,6 +124,7 @@ export function formatStatusText(card: ConnectionCard | undefined): string {
119
124
  lines.push(
120
125
  `- **Listen mode:** ${card.watching ? `on — ${card.watchSummary ?? "running"}` : "off"}`,
121
126
  );
127
+ lines.push(`- **AI footer:** ${card.aiFooter ?? "off"}`);
122
128
 
123
129
  lines.push("", "### What pi may do", "", card.permissionSummary);
124
130
  return lines.join("\n");
@@ -10,6 +10,7 @@ import { createChat, resolveParticipants } from "../graph/chats.ts";
10
10
  import { sendChatMessage } from "../graph/messages.ts";
11
11
  import { auditWrite } from "../safety/audit.ts";
12
12
  import { assertAccess } from "../safety/index.ts";
13
+ import { applyAiFooter } from "../utils/disclosure.ts";
13
14
  import { truncate } from "../utils/formatting.ts";
14
15
  import {
15
16
  AccountParam,
@@ -94,16 +95,19 @@ export const teamsCreateChatTool = {
94
95
  const lines = [`✅ Chat with **${label}** ready.`, "", `chatId: ${chat.id}`];
95
96
 
96
97
  if (params.message?.trim()) {
97
- const sent = await sendChatMessage(conn, chat.id, { body: params.message }, signal);
98
+ // The first message of a new chat is a message like any other, so the
99
+ // disclosure applies to it too.
100
+ const body = applyAiFooter(params.message, conn.aiFooter);
101
+ const sent = await sendChatMessage(conn, chat.id, { body }, signal);
98
102
  auditWrite(conn.audit, {
99
103
  tool: "teams_create_chat",
100
104
  account: conn.account,
101
105
  tenant: conn.tenant,
102
106
  actor: me?.upn,
103
107
  target: `chat:${label}`,
104
- summary: truncate(params.message, 200),
108
+ summary: truncate(body, 200),
105
109
  });
106
- lines.push("", `Sent as ${me?.displayName ?? "you"}:`, "", `> ${truncate(params.message, 300)}`, "", `messageId: ${sent.id}`);
110
+ lines.push("", `Sent as ${me?.displayName ?? "you"}:`, "", `> ${truncate(body, 300)}`, "", `messageId: ${sent.id}`);
107
111
  }
108
112
 
109
113
  return textResult(lines.join("\n"), { chatId: chat.id, participants: resolved.length });
@@ -10,6 +10,7 @@ import { Type } from "typebox";
10
10
  import { replyToChannelMessage, sendChannelMessage } from "../graph/messages.ts";
11
11
  import { resolveUserId } from "../graph/me.ts";
12
12
  import { auditWrite } from "../safety/audit.ts";
13
+ import { applyAiFooter } from "../utils/disclosure.ts";
13
14
  import { assertAccess } from "../safety/index.ts";
14
15
  import { truncate } from "../utils/formatting.ts";
15
16
  import { requireChannel } from "./resolve.ts";
@@ -115,13 +116,16 @@ export const teamsSendChannelMessageTool = {
115
116
 
116
117
  const me = await currentUser(conn, signal).catch(() => undefined);
117
118
  const target = `${team.displayName}/${channel.displayName}`;
119
+ // The disclosure is added here, not asked for: what leaves the account
120
+ // has to carry it whether or not the model remembered.
121
+ const body = applyAiFooter(params.body, conn.aiFooter, { html: params.html });
118
122
 
119
123
  try {
120
124
  const sent = await sendChannelMessage(
121
125
  conn,
122
126
  channel,
123
127
  {
124
- body: params.body,
128
+ body,
125
129
  html: params.html,
126
130
  subject: params.subject,
127
131
  importance: params.importance,
@@ -136,14 +140,14 @@ export const teamsSendChannelMessageTool = {
136
140
  tenant: conn.tenant,
137
141
  actor: me?.upn,
138
142
  target: `channel:${target}`,
139
- summary: truncate(params.body, 200),
143
+ summary: truncate(body, 200),
140
144
  });
141
145
 
142
146
  return textResult(
143
147
  [
144
148
  `✅ Posted in **${target}** as ${me?.displayName ?? "you"} (${connectionLabel(conn)}).`,
145
149
  "",
146
- `> ${truncate(params.body, 300)}`,
150
+ `> ${truncate(body, 300)}`,
147
151
  "",
148
152
  `messageId: ${sent.id}`,
149
153
  ].join("\n"),
@@ -156,7 +160,7 @@ export const teamsSendChannelMessageTool = {
156
160
  tenant: conn.tenant,
157
161
  actor: me?.upn,
158
162
  target: `channel:${target}`,
159
- summary: truncate(params.body, 200),
163
+ summary: truncate(body, 200),
160
164
  error: err instanceof Error ? err.message : String(err),
161
165
  });
162
166
  throw err;
@@ -204,6 +208,9 @@ export const teamsReplyChannelMessageTool = {
204
208
 
205
209
  const me = await currentUser(conn, signal).catch(() => undefined);
206
210
  const target = `${team.displayName}/${channel.displayName}`;
211
+ // The disclosure is added here, not asked for: what leaves the account
212
+ // has to carry it whether or not the model remembered.
213
+ const body = applyAiFooter(params.body, conn.aiFooter, { html: params.html });
207
214
 
208
215
  try {
209
216
  const sent = await replyToChannelMessage(
@@ -211,7 +218,7 @@ export const teamsReplyChannelMessageTool = {
211
218
  channel,
212
219
  params.messageId,
213
220
  {
214
- body: params.body,
221
+ body,
215
222
  html: params.html,
216
223
  importance: params.importance,
217
224
  mentions,
@@ -225,14 +232,14 @@ export const teamsReplyChannelMessageTool = {
225
232
  tenant: conn.tenant,
226
233
  actor: me?.upn,
227
234
  target: `channel:${target}#${params.messageId}`,
228
- summary: truncate(params.body, 200),
235
+ summary: truncate(body, 200),
229
236
  });
230
237
 
231
238
  return textResult(
232
239
  [
233
240
  `✅ Replied in **${target}** (thread ${params.messageId}) as ${me?.displayName ?? "you"}.`,
234
241
  "",
235
- `> ${truncate(params.body, 300)}`,
242
+ `> ${truncate(body, 300)}`,
236
243
  "",
237
244
  `messageId: ${sent.id}`,
238
245
  ].join("\n"),
@@ -245,7 +252,7 @@ export const teamsReplyChannelMessageTool = {
245
252
  tenant: conn.tenant,
246
253
  actor: me?.upn,
247
254
  target: `channel:${target}#${params.messageId}`,
248
- summary: truncate(params.body, 200),
255
+ summary: truncate(body, 200),
249
256
  error: err instanceof Error ? err.message : String(err),
250
257
  });
251
258
  throw err;