@patimweb/pi-teams 1.4.1 โ 1.5.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 +8 -6
- package/package.json +1 -1
- package/skills/teams-collaboration/SKILL.md +5 -5
- package/src/config/index.ts +8 -7
- package/src/tools/teams-send-chat-message.ts +1 -1
- package/src/watch/index.ts +48 -0
- package/src/watch/loop.ts +31 -3
- package/src/watch/prompt.ts +77 -13
package/README.md
CHANGED
|
@@ -358,8 +358,8 @@ your own company can be `confirm` while a customer tenant stays `readonly`.
|
|
|
358
358
|
|
|
359
359
|
### AI disclosure footer
|
|
360
360
|
|
|
361
|
-
|
|
362
|
-
|
|
361
|
+
On by default. Every message, reply and edit pi sends in your name ends with a
|
|
362
|
+
short note saying it was written by an AI:
|
|
363
363
|
|
|
364
364
|
```json
|
|
365
365
|
"aiFooter": { "enabled": true, "text": "๐ค Erstellt mit pi (KI-Assistent)" }
|
|
@@ -367,11 +367,13 @@ ends with a short note saying it was written by an AI:
|
|
|
367
367
|
|
|
368
368
|
| Key | Default | Meaning |
|
|
369
369
|
|-----|---------|---------|
|
|
370
|
-
| `enabled` | `
|
|
370
|
+
| `enabled` | `true` | append the note to everything pi sends |
|
|
371
371
|
| `text` | `๐ค Generated with pi (an AI agent)` | the wording; empty means the default |
|
|
372
372
|
|
|
373
|
-
|
|
374
|
-
|
|
373
|
+
Turn it off with `"enabled": false` if the disclosure does not fit the people
|
|
374
|
+
you write to. Set it globally or per account (the account level wins field by
|
|
375
|
+
field, so one account can use its own wording while another keeps the global
|
|
376
|
+
one).
|
|
375
377
|
|
|
376
378
|
Details worth knowing:
|
|
377
379
|
|
|
@@ -427,7 +429,7 @@ teams_permissions:
|
|
|
427
429
|
| `maxMessages` | `25` | default page size for message listings |
|
|
428
430
|
| `audit` | `true` | append every write to `~/.pi/agent/pi-teams-audit.jsonl` |
|
|
429
431
|
| `scopes` | see above | override the requested Graph scopes per account/tenant |
|
|
430
|
-
| `aiFooter` |
|
|
432
|
+
| `aiFooter` | on | append an AI disclosure to every message pi sends โ see above |
|
|
431
433
|
| `graphBaseUrl` / `authorityHost` | Microsoft public cloud | sovereign cloud endpoints |
|
|
432
434
|
|
|
433
435
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patimweb/pi-teams",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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,11 +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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
117
|
+
composed it โ that disclosure is configuration, not judgement: the sending
|
|
118
|
+
tools append it themselves (`aiFooter`, on by default), so do not write it
|
|
119
|
+
into the body or it arrives twice (the tools detect the duplicate, but the
|
|
120
|
+
user sees the text in the confirmation twice as well, which looks like a
|
|
121
|
+
mistake).
|
|
122
122
|
- Match the register of the conversation you just read. A channel post and a
|
|
123
123
|
quick reply to a colleague are not the same thing.
|
|
124
124
|
- Show the exact text and the destination before sending whenever the request
|
package/src/config/index.ts
CHANGED
|
@@ -137,13 +137,14 @@ export interface ResolvedWatchConfig {
|
|
|
137
137
|
/**
|
|
138
138
|
* The note pi can append to everything it sends in the user's name.
|
|
139
139
|
*
|
|
140
|
-
*
|
|
141
|
-
* a
|
|
142
|
-
*
|
|
143
|
-
*
|
|
140
|
+
* On by default, and deliberately so: pi writes as the user, and a recipient
|
|
141
|
+
* who reads a message as coming from a colleague should be able to tell that a
|
|
142
|
+
* model composed it. A disclosure that depends on remembering to switch it on
|
|
143
|
+
* is not a disclosure, so the safe state is the default one โ an organisation
|
|
144
|
+
* that does not want it turns it off with `enabled: false`.
|
|
144
145
|
*/
|
|
145
146
|
export interface AiFooterConfig {
|
|
146
|
-
/** Append the note to every outgoing message (default:
|
|
147
|
+
/** Append the note to every outgoing message (default: true) */
|
|
147
148
|
enabled?: boolean;
|
|
148
149
|
/** The note itself (default: see AI_FOOTER_DEFAULT) */
|
|
149
150
|
text?: string;
|
|
@@ -294,7 +295,7 @@ const DEFAULTS = {
|
|
|
294
295
|
authMode: "auto" as AuthMode,
|
|
295
296
|
maxMessages: 25,
|
|
296
297
|
audit: true,
|
|
297
|
-
aiFooter:
|
|
298
|
+
aiFooter: true,
|
|
298
299
|
graphBaseUrl: "https://graph.microsoft.com/v1.0",
|
|
299
300
|
authorityHost: "https://login.microsoftonline.com",
|
|
300
301
|
};
|
|
@@ -901,7 +902,7 @@ const TEMPLATE_JSON = `{
|
|
|
901
902
|
"safetyLevel": "confirm",
|
|
902
903
|
"maxMessages": 25,
|
|
903
904
|
"audit": true,
|
|
904
|
-
"aiFooter": { "enabled":
|
|
905
|
+
"aiFooter": { "enabled": true, "text": "๐ค Generated with pi (an AI agent)" }
|
|
905
906
|
}
|
|
906
907
|
`;
|
|
907
908
|
|
|
@@ -69,7 +69,7 @@ export const teamsSendChatMessageTool = {
|
|
|
69
69
|
promptGuidelines: [
|
|
70
70
|
"Write the message in the user's voice โ it is sent from their account, not from an assistant.",
|
|
71
71
|
"Show the user the exact text before sending when the intent is even slightly ambiguous.",
|
|
72
|
-
"Do not add signatures,
|
|
72
|
+
"Do not add signatures, or a note that the message was written by an AI: the sending tool appends the configured AI disclosure itself (on by default), so writing one into the body duplicates it.",
|
|
73
73
|
"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.",
|
|
74
74
|
"Answer in the language of the conversation you are writing into, and match its register.",
|
|
75
75
|
],
|
package/src/watch/index.ts
CHANGED
|
@@ -192,6 +192,54 @@ export function isWatchedSender(message: MessageSummary, watch: ResolvedWatchCon
|
|
|
192
192
|
return watch.from.some((pattern) => candidates.some((candidate) => matchesPattern(pattern, candidate)));
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// What the answer has to cover
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* How many recent messages of a chat the watcher pulls in.
|
|
201
|
+
*
|
|
202
|
+
* One would be enough to decide *whether* to wake, and that is what this used to
|
|
203
|
+
* be โ but not enough to answer well: someone who sends three lines in a row
|
|
204
|
+
* leaves three open messages, and a reply to only the newest reads as if the
|
|
205
|
+
* other two were ignored. The window is small on purpose; it only exists to
|
|
206
|
+
* carry the context of the newest message, not to replay the conversation.
|
|
207
|
+
*/
|
|
208
|
+
export const WATCH_MESSAGE_WINDOW = 10;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The messages in this chat that are still open for the user, oldest first.
|
|
212
|
+
*
|
|
213
|
+
* "Open" is the Teams read cursor talking, exactly as `isUnread` means it: a
|
|
214
|
+
* message from somebody else that the user has not caught up with yet. Messages
|
|
215
|
+
* of my own are dropped โ pi posts as the user, so they are the answers that
|
|
216
|
+
* were already given, not questions waiting for one.
|
|
217
|
+
*
|
|
218
|
+
* `messages` is newest first, as Graph returns it. A `readAt` Teams will not
|
|
219
|
+
* give is treated as "all of them are open" rather than as settled, for the same
|
|
220
|
+
* reason `isUnread` does: a read state we cannot read must not silently switch
|
|
221
|
+
* listen mode off.
|
|
222
|
+
*/
|
|
223
|
+
export function openMessages(
|
|
224
|
+
messages: MessageSummary[],
|
|
225
|
+
readAt: string | undefined,
|
|
226
|
+
me: SignedInUser | undefined,
|
|
227
|
+
): MessageSummary[] {
|
|
228
|
+
const read = readAt ? new Date(readAt).getTime() : Number.NaN;
|
|
229
|
+
const known = Number.isFinite(read);
|
|
230
|
+
|
|
231
|
+
return messages
|
|
232
|
+
.filter((entry) => {
|
|
233
|
+
if (entry.deletedDateTime) return false;
|
|
234
|
+
if (isFromMe(entry.from, me)) return false;
|
|
235
|
+
if (!known) return true;
|
|
236
|
+
|
|
237
|
+
const at = entry.createdDateTime ? new Date(entry.createdDateTime).getTime() : Number.NaN;
|
|
238
|
+
return Number.isFinite(at) && at > read;
|
|
239
|
+
})
|
|
240
|
+
.reverse();
|
|
241
|
+
}
|
|
242
|
+
|
|
195
243
|
// ---------------------------------------------------------------------------
|
|
196
244
|
// Whether a message should wake pi
|
|
197
245
|
// ---------------------------------------------------------------------------
|
package/src/watch/loop.ts
CHANGED
|
@@ -33,9 +33,11 @@ import {
|
|
|
33
33
|
noteFailure,
|
|
34
34
|
noteSuccess,
|
|
35
35
|
noteWake,
|
|
36
|
+
openMessages,
|
|
36
37
|
pruneState,
|
|
37
38
|
shouldWake,
|
|
38
39
|
wakesThisHour,
|
|
40
|
+
WATCH_MESSAGE_WINDOW,
|
|
39
41
|
type WatchState,
|
|
40
42
|
} from "./index.ts";
|
|
41
43
|
|
|
@@ -46,7 +48,17 @@ import {
|
|
|
46
48
|
/** One message that deserves a model turn. */
|
|
47
49
|
export interface WatchEvent {
|
|
48
50
|
chat: ChatSummary;
|
|
51
|
+
/** The newest message in the chat โ the one the wake was decided on */
|
|
49
52
|
message: MessageSummary;
|
|
53
|
+
/**
|
|
54
|
+
* Everything still open in that chat, oldest first, the newest included.
|
|
55
|
+
*
|
|
56
|
+
* Waking up is about one message, answering is usually about more than one:
|
|
57
|
+
* three lines sent in a row are three open messages, and answering only the
|
|
58
|
+
* last of them is what the user notices. Empty means the trigger was the only
|
|
59
|
+
* thing the read cursor left open.
|
|
60
|
+
*/
|
|
61
|
+
backlog?: MessageSummary[];
|
|
50
62
|
/** Epoch ms the wake was decided */
|
|
51
63
|
wokeAt: number;
|
|
52
64
|
/** Who pi is acting as, carried along for the prompt */
|
|
@@ -88,7 +100,11 @@ export const MAX_EXAMINATIONS_PER_TICK = 5;
|
|
|
88
100
|
export interface WatchTickDeps {
|
|
89
101
|
/** Newest chats, most recent activity first */
|
|
90
102
|
listChats: () => Promise<ChatSummary[]>;
|
|
91
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* A window of the chat's most recent messages, newest first โ enough to find
|
|
105
|
+
* the newest one and whatever the user has not read yet (see
|
|
106
|
+
* `WATCH_MESSAGE_WINDOW`).
|
|
107
|
+
*/
|
|
92
108
|
listMessages: (chatId: string) => Promise<MessageSummary[]>;
|
|
93
109
|
/**
|
|
94
110
|
* The user's read cursor for one chat (`viewpoint.lastMessageReadDateTime`).
|
|
@@ -186,7 +202,18 @@ export async function runWatchTick(
|
|
|
186
202
|
if (!decision.wake) continue;
|
|
187
203
|
|
|
188
204
|
noteWake(state, chat.id, now);
|
|
189
|
-
|
|
205
|
+
|
|
206
|
+
// The answer has to cover the whole open thread, not just its newest line:
|
|
207
|
+
// the trigger message is the reason for the wake, the backlog is the reason
|
|
208
|
+
// for a reply that sounds like the conversation was read.
|
|
209
|
+
const backlog = openMessages(messages, readAt, me);
|
|
210
|
+
wakes.push({
|
|
211
|
+
chat,
|
|
212
|
+
message,
|
|
213
|
+
backlog: backlog.length > 0 ? backlog : [message],
|
|
214
|
+
wokeAt: now,
|
|
215
|
+
me,
|
|
216
|
+
});
|
|
190
217
|
}
|
|
191
218
|
|
|
192
219
|
// Written even when nothing woke: marking a chat examined is the progress
|
|
@@ -295,7 +322,8 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
|
|
|
295
322
|
const result = await runWatchTick(
|
|
296
323
|
{
|
|
297
324
|
listChats: () => listChats(connection, { max: 50, meId: me?.id }),
|
|
298
|
-
listMessages: (chatId) =>
|
|
325
|
+
listMessages: (chatId) =>
|
|
326
|
+
listChatMessages(connection, chatId, { max: WATCH_MESSAGE_WINDOW }),
|
|
299
327
|
readState: async (chatId) => (await getChatViewpoint(connection, chatId))?.lastMessageReadAt,
|
|
300
328
|
now: () => Date.now(),
|
|
301
329
|
allowed: (chat) => hasAccess(connection, "read", "chats", chatCandidates(chat)),
|
package/src/watch/prompt.ts
CHANGED
|
@@ -6,9 +6,13 @@
|
|
|
6
6
|
* what pi does with a message it was woken for is decided here, and nothing in
|
|
7
7
|
* the polling loop should be able to change it.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Three things the prompt must always carry: the chat ID โ so the reply cannot
|
|
10
10
|
* land in the wrong conversation, since pi posts as a person and a name is not
|
|
11
|
-
* an identifier โ
|
|
11
|
+
* an identifier โ the instruction that doing nothing is a valid answer, and
|
|
12
|
+
* every message still open in that chat. The last one is not decoration: a wake
|
|
13
|
+
* is decided on the newest message, but people send three lines in a row, and a
|
|
14
|
+
* reply that answers only the last of them is the failure mode this prompt
|
|
15
|
+
* exists to prevent.
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
import type { MessageSummary, SignedInUser } from "../types.ts";
|
|
@@ -18,6 +22,45 @@ function senderName(message: MessageSummary): string {
|
|
|
18
22
|
return message.from?.displayName ?? message.from?.upn ?? "someone";
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
/** `17:00`, or nothing when Graph did not date the message. */
|
|
26
|
+
function timeOf(message: MessageSummary): string {
|
|
27
|
+
if (!message.createdDateTime) return "";
|
|
28
|
+
const at = new Date(message.createdDateTime);
|
|
29
|
+
return Number.isFinite(at.getTime()) ? at.toLocaleTimeString() : "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function quote(text: string | undefined): string {
|
|
33
|
+
const body = text?.trim() ? text.trim() : "(no text content โ open the chat to see it)";
|
|
34
|
+
return body
|
|
35
|
+
.split("\n")
|
|
36
|
+
.map((line) => `> ${line}`)
|
|
37
|
+
.join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The open messages of the chat, oldest first.
|
|
42
|
+
*
|
|
43
|
+
* Falls back to the trigger message alone when the backlog is missing โ a wake
|
|
44
|
+
* without one still has to produce a usable prompt.
|
|
45
|
+
*/
|
|
46
|
+
function openThread(event: WatchEvent): MessageSummary[] {
|
|
47
|
+
const backlog = event.backlog?.filter((entry) => !entry.deletedDateTime) ?? [];
|
|
48
|
+
return backlog.length > 0 ? backlog : [event.message];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One numbered block per open message, oldest first. */
|
|
52
|
+
function renderThread(messages: MessageSummary[]): string {
|
|
53
|
+
return messages
|
|
54
|
+
.map((entry, index) => {
|
|
55
|
+
const newest = index === messages.length - 1;
|
|
56
|
+
const stamp = timeOf(entry);
|
|
57
|
+
const heading = [stamp, senderName(entry)].filter(Boolean).join(" ยท ");
|
|
58
|
+
const label = newest ? `${heading} โ newest` : heading;
|
|
59
|
+
return [`> ${index + 1}. ${label}`, quote(entry.text)].join("\n");
|
|
60
|
+
})
|
|
61
|
+
.join("\n>\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
21
64
|
/**
|
|
22
65
|
* The prompt for one wake.
|
|
23
66
|
*
|
|
@@ -26,28 +69,49 @@ function senderName(message: MessageSummary): string {
|
|
|
26
69
|
*/
|
|
27
70
|
export function composeWatchPrompt(event: WatchEvent, me?: SignedInUser): string {
|
|
28
71
|
const { chat, message } = event;
|
|
29
|
-
const
|
|
72
|
+
const thread = openThread(event);
|
|
30
73
|
const at = message.createdDateTime ? new Date(message.createdDateTime).toLocaleString() : "just now";
|
|
31
74
|
|
|
32
|
-
|
|
33
|
-
"
|
|
75
|
+
const header = [
|
|
76
|
+
"The following Microsoft Teams message arrived while you were idle.",
|
|
34
77
|
"",
|
|
35
78
|
`- Chat: ${chat.label} (${chat.chatType})`,
|
|
36
79
|
`- From: ${senderName(message)}`,
|
|
37
80
|
`- At: ${at}`,
|
|
38
81
|
`- Chat ID: ${chat.id}`,
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const body =
|
|
85
|
+
thread.length > 1
|
|
86
|
+
? [
|
|
87
|
+
"",
|
|
88
|
+
`Unanswered messages in this chat (${thread.length}), oldest first:`,
|
|
89
|
+
"",
|
|
90
|
+
renderThread(thread),
|
|
91
|
+
]
|
|
92
|
+
: ["", "Message:", "", quote(message.text)];
|
|
93
|
+
|
|
94
|
+
const decision =
|
|
95
|
+
thread.length > 1
|
|
96
|
+
? [
|
|
97
|
+
"Decide whether these need an answer.",
|
|
98
|
+
"",
|
|
99
|
+
"All of them are still unread in Teams, and they arrived together as one conversation. Answer every " +
|
|
100
|
+
"one of them, not just the newest โ one short reply can cover all of it, but a reply that " +
|
|
101
|
+
"ignores the earlier messages is wrong even when its wording is fine.",
|
|
102
|
+
]
|
|
103
|
+
: ["Decide whether this needs an answer."];
|
|
104
|
+
|
|
105
|
+
return [
|
|
106
|
+
...header,
|
|
107
|
+
...body,
|
|
39
108
|
"",
|
|
40
|
-
|
|
41
|
-
text
|
|
42
|
-
.split("\n")
|
|
43
|
-
.map((line) => `> ${line}`)
|
|
44
|
-
.join("\n"),
|
|
45
|
-
"",
|
|
46
|
-
"Decide whether this needs an answer.",
|
|
109
|
+
...decision,
|
|
47
110
|
"",
|
|
48
111
|
`- If it does: reply with teams_send_chat_message, passing chat: "${chat.id}". pi posts as ` +
|
|
49
112
|
`${me?.displayName ?? "me"}, so write in my voice, in the language of the conversation, as briefly as ` +
|
|
50
|
-
"the message allows. No greeting boilerplate, no signature
|
|
113
|
+
"the message allows. No greeting boilerplate, no signature, and no note that a model wrote it โ " +
|
|
114
|
+
"the sending tool appends the configured AI disclosure itself.",
|
|
51
115
|
"- If it does not: say so in one line and stop.",
|
|
52
116
|
"",
|
|
53
117
|
"Format it for a chat bubble, not a document: the answer first, three short paragraphs at most, " +
|