@patimweb/pi-teams 1.3.1 → 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 +45 -5
- package/package.json +1 -1
- package/skills/teams-collaboration/SKILL.md +16 -3
- package/src/config/index.ts +61 -1
- package/src/extension/index.ts +10 -5
- package/src/graph/chats.ts +41 -0
- package/src/graph/mappers.ts +4 -4
- package/src/safety/index.ts +16 -6
- package/src/status.ts +6 -0
- package/src/tools/teams-create-chat.ts +7 -3
- package/src/tools/teams-send-channel-message.ts +15 -8
- package/src/tools/teams-send-chat-message.ts +9 -4
- package/src/tools/teams-update-message.ts +7 -2
- package/src/tools/teams-watch.ts +43 -9
- package/src/utils/disclosure.ts +55 -0
- package/src/watch/cursor.ts +112 -0
- package/src/watch/index.ts +107 -19
- package/src/watch/loop.ts +85 -24
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
|
-
- **
|
|
86
|
-
|
|
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.
|
|
554
|
-
|
|
555
|
-
|
|
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
|
+
"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
|
-
-
|
|
300
|
-
|
|
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
|
package/src/config/index.ts
CHANGED
|
@@ -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
|
|
package/src/extension/index.ts
CHANGED
|
@@ -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";
|
|
@@ -147,9 +148,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
147
148
|
* The settings the running watcher was started from.
|
|
148
149
|
*
|
|
149
150
|
* Used to tell a real config change from a tool that merely *read* the
|
|
150
|
-
* settings.
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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.
|
|
153
154
|
*/
|
|
154
155
|
let watchSignature: string | undefined;
|
|
155
156
|
|
|
@@ -173,7 +174,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
173
174
|
const conn = refresh();
|
|
174
175
|
|
|
175
176
|
// Already watching this exact configuration: leave the loop alone, so its
|
|
176
|
-
//
|
|
177
|
+
// cooldowns survive a look at the status.
|
|
177
178
|
const signature = conn?.watch.enabled ? JSON.stringify(conn.watch) : undefined;
|
|
178
179
|
if (watchSignature !== undefined && signature === watchSignature && watchLoop) return;
|
|
179
180
|
|
|
@@ -184,6 +185,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
184
185
|
reportedWatchError = undefined;
|
|
185
186
|
const loop = startWatchLoop({
|
|
186
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),
|
|
187
192
|
onWake: (event) => {
|
|
188
193
|
pi.sendUserMessage(composeWatchPrompt(event, event.me), { deliverAs: "followUp" });
|
|
189
194
|
},
|
|
@@ -490,7 +495,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
490
495
|
|
|
491
496
|
if (target.safetyLevel === "confirm") {
|
|
492
497
|
const scope = target.tenant === target.account ? target.account : `${target.account}/${target.tenant}`;
|
|
493
|
-
const summary = formatMutationSummary(event.toolName, params);
|
|
498
|
+
const summary = formatMutationSummary(event.toolName, params, target.aiFooter);
|
|
494
499
|
const approved = await ctx.ui.confirm(
|
|
495
500
|
"Microsoft Teams — acting as you",
|
|
496
501
|
`${summary}\n\nAccount: ${scope}\n\nAllow this?`,
|
package/src/graph/chats.ts
CHANGED
|
@@ -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
|
*
|
package/src/graph/mappers.ts
CHANGED
|
@@ -143,10 +143,10 @@ export function teamCandidates(team: TeamSummary): (string | undefined)[] {
|
|
|
143
143
|
* newest message arrived minutes ago still reported a `lastUpdatedDateTime`
|
|
144
144
|
* from more than a year earlier.
|
|
145
145
|
*
|
|
146
|
-
* Everything that dates a chat depends on this — the chat list order, the
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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
150
|
* fallback for chats Graph returns without a preview.
|
|
151
151
|
*/
|
|
152
152
|
function chatActivity(raw: Raw): string | undefined {
|
package/src/safety/index.ts
CHANGED
|
@@ -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${
|
|
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${
|
|
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${
|
|
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
|
-
|
|
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${
|
|
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
|
-
|
|
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(
|
|
108
|
+
summary: truncate(body, 200),
|
|
105
109
|
});
|
|
106
|
-
lines.push("", `Sent as ${me?.displayName ?? "you"}:`, "", `> ${truncate(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
255
|
+
summary: truncate(body, 200),
|
|
249
256
|
error: err instanceof Error ? err.message : String(err),
|
|
250
257
|
});
|
|
251
258
|
throw err;
|
|
@@ -12,6 +12,7 @@ import { sendChatMessage } from "../graph/messages.ts";
|
|
|
12
12
|
import { resolveUserId } from "../graph/me.ts";
|
|
13
13
|
import { auditWrite } from "../safety/audit.ts";
|
|
14
14
|
import { assertAccess } from "../safety/index.ts";
|
|
15
|
+
import { applyAiFooter } from "../utils/disclosure.ts";
|
|
15
16
|
import { truncate } from "../utils/formatting.ts";
|
|
16
17
|
import { requireChat } from "./resolve.ts";
|
|
17
18
|
import {
|
|
@@ -108,12 +109,16 @@ export const teamsSendChatMessageTool = {
|
|
|
108
109
|
|
|
109
110
|
const me = await currentUser(conn, signal).catch(() => undefined);
|
|
110
111
|
|
|
112
|
+
// The disclosure is added here, not asked for: what leaves the account
|
|
113
|
+
// has to carry it whether or not the model remembered.
|
|
114
|
+
const body = applyAiFooter(params.body, conn.aiFooter, { html: params.html });
|
|
115
|
+
|
|
111
116
|
try {
|
|
112
117
|
const sent = await sendChatMessage(
|
|
113
118
|
conn,
|
|
114
119
|
chat.id,
|
|
115
120
|
{
|
|
116
|
-
body
|
|
121
|
+
body,
|
|
117
122
|
html: params.html,
|
|
118
123
|
importance: params.importance,
|
|
119
124
|
mentions,
|
|
@@ -127,14 +132,14 @@ export const teamsSendChatMessageTool = {
|
|
|
127
132
|
tenant: conn.tenant,
|
|
128
133
|
actor: me?.upn,
|
|
129
134
|
target: `chat:${chat.label}`,
|
|
130
|
-
summary: truncate(
|
|
135
|
+
summary: truncate(body, 200),
|
|
131
136
|
});
|
|
132
137
|
|
|
133
138
|
return textResult(
|
|
134
139
|
[
|
|
135
140
|
`✅ Message sent to **${chat.label}** as ${me?.displayName ?? "you"} (${connectionLabel(conn)}).`,
|
|
136
141
|
"",
|
|
137
|
-
`> ${truncate(
|
|
142
|
+
`> ${truncate(body, 300)}`,
|
|
138
143
|
"",
|
|
139
144
|
`messageId: ${sent.id}`,
|
|
140
145
|
].join("\n"),
|
|
@@ -147,7 +152,7 @@ export const teamsSendChatMessageTool = {
|
|
|
147
152
|
tenant: conn.tenant,
|
|
148
153
|
actor: me?.upn,
|
|
149
154
|
target: `chat:${chat.label}`,
|
|
150
|
-
summary: truncate(
|
|
155
|
+
summary: truncate(body, 200),
|
|
151
156
|
error: err instanceof Error ? err.message : String(err),
|
|
152
157
|
});
|
|
153
158
|
throw err;
|