@patimweb/pi-teams 1.4.1 โ 1.5.1
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 +14 -8
- package/package.json +1 -1
- package/skills/teams-collaboration/SKILL.md +5 -5
- package/src/config/index.ts +8 -7
- package/src/status.ts +6 -1
- package/src/tools/teams-send-chat-message.ts +23 -2
- package/src/tools/teams-watch.ts +11 -5
- package/src/watch/cursor.ts +5 -0
- package/src/watch/index.ts +169 -7
- package/src/watch/loop.ts +81 -14
- package/src/watch/prompt.ts +77 -13
package/README.md
CHANGED
|
@@ -87,7 +87,10 @@ What it does, and what it deliberately does not:
|
|
|
87
87
|
message is still unread for you in Teams. So switching listen mode back on
|
|
88
88
|
answers what you missed while pi was not running โ and a chat you already read
|
|
89
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.
|
|
90
|
+
`maxTriggersPerHour`, so a long absence does not produce a burst. A chat held
|
|
91
|
+
back by its `cooldownSeconds`, or by that hourly cap, is *delayed, never
|
|
92
|
+
dropped*: it keeps its place in line and is answered as soon as the wait is
|
|
93
|
+
over. `teams_watch action: status` shows how many are waiting right now.
|
|
91
94
|
- **Never wakes for its own messages.** pi posts as you, so its own reply comes
|
|
92
95
|
back as "my own message" and stops the loop.
|
|
93
96
|
- **Respects the read rules.** A chat excluded by `permissions.read.chats` is
|
|
@@ -358,8 +361,8 @@ your own company can be `confirm` while a customer tenant stays `readonly`.
|
|
|
358
361
|
|
|
359
362
|
### AI disclosure footer
|
|
360
363
|
|
|
361
|
-
|
|
362
|
-
|
|
364
|
+
On by default. Every message, reply and edit pi sends in your name ends with a
|
|
365
|
+
short note saying it was written by an AI:
|
|
363
366
|
|
|
364
367
|
```json
|
|
365
368
|
"aiFooter": { "enabled": true, "text": "๐ค Erstellt mit pi (KI-Assistent)" }
|
|
@@ -367,11 +370,13 @@ ends with a short note saying it was written by an AI:
|
|
|
367
370
|
|
|
368
371
|
| Key | Default | Meaning |
|
|
369
372
|
|-----|---------|---------|
|
|
370
|
-
| `enabled` | `
|
|
373
|
+
| `enabled` | `true` | append the note to everything pi sends |
|
|
371
374
|
| `text` | `๐ค Generated with pi (an AI agent)` | the wording; empty means the default |
|
|
372
375
|
|
|
373
|
-
|
|
374
|
-
|
|
376
|
+
Turn it off with `"enabled": false` if the disclosure does not fit the people
|
|
377
|
+
you write to. Set it globally or per account (the account level wins field by
|
|
378
|
+
field, so one account can use its own wording while another keeps the global
|
|
379
|
+
one).
|
|
375
380
|
|
|
376
381
|
Details worth knowing:
|
|
377
382
|
|
|
@@ -427,7 +432,7 @@ teams_permissions:
|
|
|
427
432
|
| `maxMessages` | `25` | default page size for message listings |
|
|
428
433
|
| `audit` | `true` | append every write to `~/.pi/agent/pi-teams-audit.jsonl` |
|
|
429
434
|
| `scopes` | see above | override the requested Graph scopes per account/tenant |
|
|
430
|
-
| `aiFooter` |
|
|
435
|
+
| `aiFooter` | on | append an AI disclosure to every message pi sends โ see above |
|
|
431
436
|
| `graphBaseUrl` / `authorityHost` | Microsoft public cloud | sovereign cloud endpoints |
|
|
432
437
|
|
|
433
438
|
---
|
|
@@ -650,7 +655,8 @@ network connection.
|
|
|
650
655
|
| "Blocked by configuration" | Your own scope rules. `teams_permissions action: test` shows which rule |
|
|
651
656
|
| "cannot run on an app-only token" | Set `authMode: "interactive"` and run `teams_login` |
|
|
652
657
|
| 403 reading channel messages | `ChannelMessage.Read.All` needs **admin** consent โ see "Who has to approve what" |
|
|
653
|
-
| Listen mode does nothing | `teams_watch action: status` says whether it is enabled and running.
|
|
658
|
+
| Listen mode does nothing | `teams_watch action: status` says whether it is enabled and running, how many chats it tracks, and where the cursor lives. A chat you have already read in Teams stays quiet by design, and one held back by its cooldown or the hourly cap shows up as *waiting* |
|
|
659
|
+
| Listen mode never answers one specific chat | `waiting` in `teams_watch action: status` says whether it is sitting out a `cooldownSeconds` window; chats it has already decided about are in the cursor under `~/.pi/agent/pi-teams-watch/` |
|
|
654
660
|
| Listen mode answers too much | Narrow `watch.chats` and `watch.from`, raise `cooldownSeconds`, lower `maxTriggersPerHour` |
|
|
655
661
|
| Listen mode stopped by itself | `teams_watch action: status` shows the last polling error; a revoked session or a sleeping laptop is the usual cause, and it resumes on its own |
|
|
656
662
|
| Throttled | Graph rate limit; the client retries with back-off, then reports it |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patimweb/pi-teams",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
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
|
|
package/src/status.ts
CHANGED
|
@@ -81,7 +81,12 @@ export function buildConnectionLabel(card: ConnectionCard | undefined): string {
|
|
|
81
81
|
if (!card.signedIn) return `Teams ยท ${card.account} ยท not signed in`;
|
|
82
82
|
|
|
83
83
|
const scope = card.tenant === card.account ? card.account : `${card.account}/${card.tenant}`;
|
|
84
|
-
|
|
84
|
+
// A monochrome mark, not an emoji: the rest of the label is drawn in the
|
|
85
|
+
// terminal's own font (the โ included), and a colour glyph next to it looks
|
|
86
|
+
// like a different interface. โ reads as "live", which is what the label is
|
|
87
|
+
// saying โ and it survives a terminal without emoji fonts, where ๐ would
|
|
88
|
+
// come out as tofu.
|
|
89
|
+
const listening = card.watching ? " ยท โ listening" : "";
|
|
85
90
|
return `โ Teams ยท ${scope}${card.user ? ` ยท ${card.user}` : ""}${listening}`;
|
|
86
91
|
}
|
|
87
92
|
|
|
@@ -5,10 +5,19 @@
|
|
|
5
5
|
* from the user typing it. Hence the order of operations โ resolve the chat,
|
|
6
6
|
* check the chat rules, check every participant against the people rules,
|
|
7
7
|
* resolve the mentions, send, then record the audit entry.
|
|
8
|
+
*
|
|
9
|
+
* The last step is the read cursor. Sending from Graph does not move it โ only
|
|
10
|
+
* the user opening the chat, or teams_mark_read, does โ so without this a chat
|
|
11
|
+
* pi has just answered keeps its unread marker in Teams, which is the one thing
|
|
12
|
+
* that looks like nobody dealt with it. Replying is reading, so the message
|
|
13
|
+
* would arrive with the chat still bold; the mark is best effort and comes last,
|
|
14
|
+
* because a send that went out must not be reported as failed.
|
|
8
15
|
*/
|
|
9
16
|
|
|
10
17
|
import { Type } from "typebox";
|
|
18
|
+
import { setChatReadState } from "../graph/chats.ts";
|
|
11
19
|
import { sendChatMessage } from "../graph/messages.ts";
|
|
20
|
+
import { formatGraphError } from "../utils/errors.ts";
|
|
12
21
|
import { resolveUserId } from "../graph/me.ts";
|
|
13
22
|
import { auditWrite } from "../safety/audit.ts";
|
|
14
23
|
import { assertAccess } from "../safety/index.ts";
|
|
@@ -69,7 +78,7 @@ export const teamsSendChatMessageTool = {
|
|
|
69
78
|
promptGuidelines: [
|
|
70
79
|
"Write the message in the user's voice โ it is sent from their account, not from an assistant.",
|
|
71
80
|
"Show the user the exact text before sending when the intent is even slightly ambiguous.",
|
|
72
|
-
"Do not add signatures,
|
|
81
|
+
"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
82
|
"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
83
|
"Answer in the language of the conversation you are writing into, and match its register.",
|
|
75
84
|
],
|
|
@@ -135,6 +144,18 @@ export const teamsSendChatMessageTool = {
|
|
|
135
144
|
summary: truncate(body, 200),
|
|
136
145
|
});
|
|
137
146
|
|
|
147
|
+
// Replying is reading: clear the unread marker the answer was written for.
|
|
148
|
+
// Deliberately after the audit entry, so the message that went out is
|
|
149
|
+
// recorded even when the read-mark cannot be moved.
|
|
150
|
+
let readNote = "";
|
|
151
|
+
if (me) {
|
|
152
|
+
try {
|
|
153
|
+
await setChatReadState(conn, chat.id, me, true, { signal });
|
|
154
|
+
} catch (err) {
|
|
155
|
+
readNote = `\n\nโ ๏ธ The chat could not be marked as read: ${formatGraphError(err)}`;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
138
159
|
return textResult(
|
|
139
160
|
[
|
|
140
161
|
`โ
Message sent to **${chat.label}** as ${me?.displayName ?? "you"} (${connectionLabel(conn)}).`,
|
|
@@ -142,7 +163,7 @@ export const teamsSendChatMessageTool = {
|
|
|
142
163
|
`> ${truncate(body, 300)}`,
|
|
143
164
|
"",
|
|
144
165
|
`messageId: ${sent.id}`,
|
|
145
|
-
].join("\n"),
|
|
166
|
+
].join("\n") + readNote,
|
|
146
167
|
{ chatId: chat.id, messageId: sent.id },
|
|
147
168
|
);
|
|
148
169
|
} catch (err) {
|
package/src/tools/teams-watch.ts
CHANGED
|
@@ -46,7 +46,8 @@ const CURSOR_NOTES = [
|
|
|
46
46
|
+ "on therefore answers what you missed, and a chat you have already read in Teams stays quiet.",
|
|
47
47
|
"",
|
|
48
48
|
"A backlog is drained over several ticks and capped by the hourly wake limit, so a long absence does "
|
|
49
|
-
+ "not produce a burst of answers."
|
|
49
|
+
+ "not produce a burst of answers. A chat held back by its cooldown or by that limit is delayed, "
|
|
50
|
+
+ "never dropped: it stays open and is answered as soon as the wait is over.",
|
|
50
51
|
].join("\n");
|
|
51
52
|
|
|
52
53
|
/** Renders the effective settings the same way the config file would. */
|
|
@@ -93,6 +94,12 @@ function runtimeLines(runtime: WatchRuntimeStatus | undefined, conn: TeamsConnec
|
|
|
93
94
|
`- chats tracked: ${runtime.trackedChats}`,
|
|
94
95
|
`- woken this hour: ${runtime.wakesThisHour}`,
|
|
95
96
|
];
|
|
97
|
+
if (runtime.waiting > 0) {
|
|
98
|
+
lines.push(
|
|
99
|
+
`- waiting: ${runtime.waiting} chat(s) โ held back by their cooldown or by the wake limit, "
|
|
100
|
+
+ "and answered once that clears`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
96
103
|
if (runtime.lastTickAt) {
|
|
97
104
|
lines.push(`- last poll: ${new Date(runtime.lastTickAt).toLocaleTimeString()}`);
|
|
98
105
|
}
|
|
@@ -115,9 +122,8 @@ export const teamsWatchTool = {
|
|
|
115
122
|
name: "teams_watch",
|
|
116
123
|
description:
|
|
117
124
|
"Control listen mode: pi polls your Teams chats and turns an incoming message into a prompt it answers. " +
|
|
118
|
-
"A chat wakes it when it has moved since pi last looked, so switching listen mode on also answers "
|
|
119
|
-
|
|
120
|
-
"without answering it. " +
|
|
125
|
+
"A chat wakes it when it has moved since pi last looked, so switching listen mode on also answers "
|
|
126
|
+
+ "what arrived while pi was not running. " +
|
|
121
127
|
"Use action 'status' to see the current settings, 'enable' or 'disable' to switch it, and the optional " +
|
|
122
128
|
"fields to narrow what may wake pi (chat patterns, mentions only, interval, cooldown, hourly limit). " +
|
|
123
129
|
"The watcher runs in the extension session; this tool changes the configuration it reads.",
|
|
@@ -216,7 +222,7 @@ export const teamsWatchTool = {
|
|
|
216
222
|
"",
|
|
217
223
|
...runtimeLines(runtime, conn),
|
|
218
224
|
"",
|
|
219
|
-
|
|
225
|
+
CURSOR_NOTES,
|
|
220
226
|
].join("\n"),
|
|
221
227
|
{ watch, runtime },
|
|
222
228
|
);
|
package/src/watch/cursor.ts
CHANGED
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* the stored one. Written after every tick, so a crash costs nothing but the
|
|
16
16
|
* tick it died in.
|
|
17
17
|
*
|
|
18
|
+
* What lands here is a *decision*, not a sighting: a chat whose wake was only
|
|
19
|
+
* postponed โ inside its cooldown, or over the hourly limit โ is deliberately
|
|
20
|
+
* kept out of the file (see `noteDeferral` in ./index.ts), because an entry here
|
|
21
|
+
* means the watcher will never look at that chat again.
|
|
22
|
+
*
|
|
18
23
|
* One file per account **and** tenant, next to the token cache: two accounts in
|
|
19
24
|
* one process must not share a cursor, and neither must a guest tenant beneath
|
|
20
25
|
* the same account.
|
package/src/watch/index.ts
CHANGED
|
@@ -51,6 +51,20 @@ export interface WatchState {
|
|
|
51
51
|
lastTriggered: Map<string, number>;
|
|
52
52
|
/** epoch ms of every wake, pruned to the last hour */
|
|
53
53
|
triggers: number[];
|
|
54
|
+
/**
|
|
55
|
+
* chatId โ epoch ms before which this chat must not be looked at again.
|
|
56
|
+
*
|
|
57
|
+
* The counterpart of `seen`, and the whole point of the pair: a chat whose
|
|
58
|
+
* decision was only *postponed* โ inside its cooldown, or held back by the
|
|
59
|
+
* hourly wake limit โ is deliberately **not** written to the cursor, because
|
|
60
|
+
* the cursor means "pi has looked at this and will not look again". A message
|
|
61
|
+
* written down there is a message nobody ever answers.
|
|
62
|
+
*
|
|
63
|
+
* In memory on purpose: it only saves the Graph calls of re-examining the chat
|
|
64
|
+
* every tick until the wait is over, so losing it on a restart costs one extra
|
|
65
|
+
* examination โ never a message.
|
|
66
|
+
*/
|
|
67
|
+
retryAfter: Map<string, number>;
|
|
54
68
|
}
|
|
55
69
|
|
|
56
70
|
/**
|
|
@@ -67,7 +81,42 @@ export const MAX_CHAT_ATTEMPTS = 3;
|
|
|
67
81
|
* Omitted means the very first run, which is the only run that skips history.
|
|
68
82
|
*/
|
|
69
83
|
export function createWatchState(seen?: Iterable<[string, string]>): WatchState {
|
|
70
|
-
return {
|
|
84
|
+
return {
|
|
85
|
+
seen: new Map(seen),
|
|
86
|
+
attempts: new Map(),
|
|
87
|
+
lastTriggered: new Map(),
|
|
88
|
+
triggers: [],
|
|
89
|
+
retryAfter: new Map(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Record that a chat has been looked at and decided.
|
|
95
|
+
*
|
|
96
|
+
* "Decided" is the operative word: only a final decision may write the cursor,
|
|
97
|
+
* since the cursor is what stops the watcher from ever looking at that chat
|
|
98
|
+
* again. A postponed chat goes to `noteDeferral` instead.
|
|
99
|
+
*/
|
|
100
|
+
export function settleChat(state: WatchState, chatId: string, marker: string): void {
|
|
101
|
+
state.seen.set(chatId, marker);
|
|
102
|
+
state.retryAfter.delete(chatId);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Keep a chat open โ and out of the batch โ until `until`.
|
|
107
|
+
*
|
|
108
|
+
* Not writing the cursor is what keeps the message; this is only the throttle
|
|
109
|
+
* on how often the chat is re-read while it waits.
|
|
110
|
+
*/
|
|
111
|
+
export function noteDeferral(state: WatchState, chatId: string, until: number): void {
|
|
112
|
+
state.retryAfter.set(chatId, until);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** How many chats are waiting out a cooldown or the wake limit right now. */
|
|
116
|
+
export function waitingCount(state: WatchState, now: number): number {
|
|
117
|
+
let waiting = 0;
|
|
118
|
+
for (const until of state.retryAfter.values()) if (until > now) waiting += 1;
|
|
119
|
+
return waiting;
|
|
71
120
|
}
|
|
72
121
|
|
|
73
122
|
/** Record a failed examination; true while the chat is still worth retrying. */
|
|
@@ -122,6 +171,12 @@ export function pruneState(state: WatchState, now: number): void {
|
|
|
122
171
|
state.seen = new Map(byMarker.slice(0, MAX_CURSOR_ENTRIES));
|
|
123
172
|
}
|
|
124
173
|
|
|
174
|
+
// Waiting chats expire by themselves โ a deferral can never outlive the hour
|
|
175
|
+
// the wake limit looks at, so there is nothing to bound here.
|
|
176
|
+
for (const [chatId, until] of state.retryAfter) {
|
|
177
|
+
if (until <= now) state.retryAfter.delete(chatId);
|
|
178
|
+
}
|
|
179
|
+
|
|
125
180
|
// Counters are bounded by size rather than by the cursor: a chat that keeps
|
|
126
181
|
// failing is deliberately *not* in the cursor, and its counter is what stops
|
|
127
182
|
// it from being retried forever.
|
|
@@ -149,6 +204,7 @@ export function chatsToExamine(
|
|
|
149
204
|
state: WatchState,
|
|
150
205
|
chats: ChatSummary[],
|
|
151
206
|
watch: ResolvedWatchConfig,
|
|
207
|
+
now: number,
|
|
152
208
|
): ChatSummary[] {
|
|
153
209
|
return chats.filter((chat) => {
|
|
154
210
|
if (!isWatchedChat(chat, watch)) return false;
|
|
@@ -158,7 +214,15 @@ export function chatsToExamine(
|
|
|
158
214
|
// "new" โ and never marked, which costs no Graph call at all.
|
|
159
215
|
if (!marker) return false;
|
|
160
216
|
|
|
161
|
-
|
|
217
|
+
if (state.seen.get(chat.id) === marker) return false;
|
|
218
|
+
|
|
219
|
+
// Moved, but the decision on it is only postponed: the chat is still open
|
|
220
|
+
// (it is not in the cursor), but looking at it again right now would only
|
|
221
|
+
// re-read the same message. Deliberately after the cursor check, and
|
|
222
|
+
// deliberately out of the batch rather than in it โ a chat waiting out its
|
|
223
|
+
// cooldown must not cost another chat its slot in the tick.
|
|
224
|
+
const until = state.retryAfter.get(chat.id);
|
|
225
|
+
return until === undefined || until <= now;
|
|
162
226
|
});
|
|
163
227
|
}
|
|
164
228
|
|
|
@@ -192,15 +256,91 @@ export function isWatchedSender(message: MessageSummary, watch: ResolvedWatchCon
|
|
|
192
256
|
return watch.from.some((pattern) => candidates.some((candidate) => matchesPattern(pattern, candidate)));
|
|
193
257
|
}
|
|
194
258
|
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// What the answer has to cover
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* How many recent messages of a chat the watcher pulls in.
|
|
265
|
+
*
|
|
266
|
+
* One would be enough to decide *whether* to wake, and that is what this used to
|
|
267
|
+
* be โ but not enough to answer well: someone who sends three lines in a row
|
|
268
|
+
* leaves three open messages, and a reply to only the newest reads as if the
|
|
269
|
+
* other two were ignored. The window is small on purpose; it only exists to
|
|
270
|
+
* carry the context of the newest message, not to replay the conversation.
|
|
271
|
+
*/
|
|
272
|
+
export const WATCH_MESSAGE_WINDOW = 10;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The messages in this chat that are still open for the user, oldest first.
|
|
276
|
+
*
|
|
277
|
+
* "Open" is the Teams read cursor talking, exactly as `isUnread` means it: a
|
|
278
|
+
* message from somebody else that the user has not caught up with yet. Messages
|
|
279
|
+
* of my own are dropped โ pi posts as the user, so they are the answers that
|
|
280
|
+
* were already given, not questions waiting for one.
|
|
281
|
+
*
|
|
282
|
+
* `messages` is newest first, as Graph returns it. A `readAt` Teams will not
|
|
283
|
+
* give is treated as "all of them are open" rather than as settled, for the same
|
|
284
|
+
* reason `isUnread` does: a read state we cannot read must not silently switch
|
|
285
|
+
* listen mode off.
|
|
286
|
+
*/
|
|
287
|
+
export function openMessages(
|
|
288
|
+
messages: MessageSummary[],
|
|
289
|
+
readAt: string | undefined,
|
|
290
|
+
me: SignedInUser | undefined,
|
|
291
|
+
): MessageSummary[] {
|
|
292
|
+
const read = readAt ? new Date(readAt).getTime() : Number.NaN;
|
|
293
|
+
const known = Number.isFinite(read);
|
|
294
|
+
|
|
295
|
+
return messages
|
|
296
|
+
.filter((entry) => {
|
|
297
|
+
if (entry.deletedDateTime) return false;
|
|
298
|
+
if (isFromMe(entry.from, me)) return false;
|
|
299
|
+
if (!known) return true;
|
|
300
|
+
|
|
301
|
+
const at = entry.createdDateTime ? new Date(entry.createdDateTime).getTime() : Number.NaN;
|
|
302
|
+
return Number.isFinite(at) && at > read;
|
|
303
|
+
})
|
|
304
|
+
.reverse();
|
|
305
|
+
}
|
|
306
|
+
|
|
195
307
|
// ---------------------------------------------------------------------------
|
|
196
308
|
// Whether a message should wake pi
|
|
197
309
|
// ---------------------------------------------------------------------------
|
|
198
310
|
|
|
199
|
-
export type WakeDecision =
|
|
311
|
+
export type WakeDecision =
|
|
312
|
+
| { wake: true }
|
|
313
|
+
| {
|
|
314
|
+
wake: false;
|
|
315
|
+
reason: string;
|
|
316
|
+
/**
|
|
317
|
+
* Set when the skip is only for now: ms to wait before looking again.
|
|
318
|
+
* Absent means the decision is final โ this message is not for pi.
|
|
319
|
+
*/
|
|
320
|
+
retryAfterMs?: number;
|
|
321
|
+
};
|
|
200
322
|
|
|
201
323
|
const WAKE: WakeDecision = { wake: true };
|
|
324
|
+
|
|
325
|
+
/** Final: this message will never be one of pi's. */
|
|
202
326
|
const skip = (reason: string): WakeDecision => ({ wake: false, reason });
|
|
203
327
|
|
|
328
|
+
/** Only for now: the message stays open and is looked at again after the wait. */
|
|
329
|
+
const defer = (reason: string, retryAfterMs: number): WakeDecision => ({
|
|
330
|
+
wake: false,
|
|
331
|
+
reason,
|
|
332
|
+
retryAfterMs: Math.max(MIN_RETRY_MS, Math.round(retryAfterMs)),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Floor on a retry delay.
|
|
337
|
+
*
|
|
338
|
+
* A cooldown that is a second away, or a wake limit about to slide, would
|
|
339
|
+
* otherwise turn the deferral into "examine this chat again on every tick" โ
|
|
340
|
+
* the point of waiting is to not pay for the same Graph calls until it matters.
|
|
341
|
+
*/
|
|
342
|
+
const MIN_RETRY_MS = 5_000;
|
|
343
|
+
|
|
204
344
|
/**
|
|
205
345
|
* Is the newest message in this chat still unread for the user?
|
|
206
346
|
*
|
|
@@ -227,8 +367,13 @@ export function isUnread(chat: ChatSummary, lastMessageReadAt?: string): boolean
|
|
|
227
367
|
* Should this message wake pi?
|
|
228
368
|
*
|
|
229
369
|
* Ordered so the cheapest and most obvious reason to stay quiet is also the
|
|
230
|
-
* one that gets reported
|
|
231
|
-
*
|
|
370
|
+
* one that gets reported.
|
|
371
|
+
*
|
|
372
|
+
* The two kinds of "no" are told apart here, and it matters: a message from
|
|
373
|
+
* somebody pi does not listen to will never become an answer, while a chat in
|
|
374
|
+
* its cooldown or a burst over the hourly limit is only waiting. Those two are
|
|
375
|
+
* returned with a `retryAfterMs`, and the loop uses it to keep the chat open
|
|
376
|
+
* instead of writing it off in the cursor.
|
|
232
377
|
*/
|
|
233
378
|
export function shouldWake(
|
|
234
379
|
chat: ChatSummary,
|
|
@@ -245,17 +390,34 @@ export function shouldWake(
|
|
|
245
390
|
if (!isWatchedSender(message, watch)) return skip("the sender is outside the configured listen list");
|
|
246
391
|
if (watch.mentionOnly && !mentionsMe(message, me)) return skip("the chat is set to mentions only");
|
|
247
392
|
if (watch.maxTriggersPerHour > 0 && wakesThisHour(state, now) >= watch.maxTriggersPerHour) {
|
|
248
|
-
return
|
|
393
|
+
return defer(
|
|
394
|
+
"the hourly wake limit is reached",
|
|
395
|
+
nextWakeSlot(state, now, watch.maxTriggersPerHour) - now,
|
|
396
|
+
);
|
|
249
397
|
}
|
|
250
398
|
|
|
251
399
|
const last = state.lastTriggered.get(chat.id);
|
|
252
400
|
if (last !== undefined && now - last < watch.cooldownSeconds * 1000) {
|
|
253
|
-
return
|
|
401
|
+
return defer("this chat is inside its cooldown", last + watch.cooldownSeconds * 1000 - now);
|
|
254
402
|
}
|
|
255
403
|
|
|
256
404
|
return WAKE;
|
|
257
405
|
}
|
|
258
406
|
|
|
407
|
+
/**
|
|
408
|
+
* When the next wake fits under the hourly limit.
|
|
409
|
+
*
|
|
410
|
+
* The limit is a sliding window, not a bucket that empties on the hour, so the
|
|
411
|
+
* answer is "when the oldest wake of the last hour falls out of it". That is
|
|
412
|
+
* what keeps a burst โ a Monday morning, a release โ from silencing a chat for
|
|
413
|
+
* the rest of the hour.
|
|
414
|
+
*/
|
|
415
|
+
export function nextWakeSlot(state: WatchState, now: number, maxPerHour: number): number {
|
|
416
|
+
const recent = state.triggers.filter((at) => now - at < 3600_000).sort((a, b) => a - b);
|
|
417
|
+
if (maxPerHour < 1 || recent.length < maxPerHour) return now;
|
|
418
|
+
return recent[recent.length - maxPerHour] + 3600_000;
|
|
419
|
+
}
|
|
420
|
+
|
|
259
421
|
/**
|
|
260
422
|
* Record that pi was woken for a chat.
|
|
261
423
|
*
|
package/src/watch/loop.ts
CHANGED
|
@@ -30,12 +30,17 @@ import {
|
|
|
30
30
|
chatsToExamine,
|
|
31
31
|
createWatchState,
|
|
32
32
|
isUnread,
|
|
33
|
+
noteDeferral,
|
|
33
34
|
noteFailure,
|
|
34
35
|
noteSuccess,
|
|
35
36
|
noteWake,
|
|
37
|
+
openMessages,
|
|
36
38
|
pruneState,
|
|
39
|
+
settleChat,
|
|
37
40
|
shouldWake,
|
|
41
|
+
waitingCount,
|
|
38
42
|
wakesThisHour,
|
|
43
|
+
WATCH_MESSAGE_WINDOW,
|
|
39
44
|
type WatchState,
|
|
40
45
|
} from "./index.ts";
|
|
41
46
|
|
|
@@ -46,7 +51,17 @@ import {
|
|
|
46
51
|
/** One message that deserves a model turn. */
|
|
47
52
|
export interface WatchEvent {
|
|
48
53
|
chat: ChatSummary;
|
|
54
|
+
/** The newest message in the chat โ the one the wake was decided on */
|
|
49
55
|
message: MessageSummary;
|
|
56
|
+
/**
|
|
57
|
+
* Everything still open in that chat, oldest first, the newest included.
|
|
58
|
+
*
|
|
59
|
+
* Waking up is about one message, answering is usually about more than one:
|
|
60
|
+
* three lines sent in a row are three open messages, and answering only the
|
|
61
|
+
* last of them is what the user notices. Empty means the trigger was the only
|
|
62
|
+
* thing the read cursor left open.
|
|
63
|
+
*/
|
|
64
|
+
backlog?: MessageSummary[];
|
|
50
65
|
/** Epoch ms the wake was decided */
|
|
51
66
|
wokeAt: number;
|
|
52
67
|
/** Who pi is acting as, carried along for the prompt */
|
|
@@ -66,6 +81,12 @@ export interface WatchRuntimeStatus {
|
|
|
66
81
|
wakesThisHour: number;
|
|
67
82
|
/** Chats seen so far โ a quick sanity signal that the loop is alive */
|
|
68
83
|
trackedChats: number;
|
|
84
|
+
/**
|
|
85
|
+
* Chats still open but held back right now โ inside their cooldown, or waiting
|
|
86
|
+
* for a slot under the hourly limit. The visible form of "not lost, not yet
|
|
87
|
+
* answered": without it, a burst that hits the limit looks like silence.
|
|
88
|
+
*/
|
|
89
|
+
waiting: number;
|
|
69
90
|
/** How many chats had moved when the watcher came up, before the per-tick cap */
|
|
70
91
|
catchUp?: number;
|
|
71
92
|
/** Of those, how many the user had already read in Teams */
|
|
@@ -88,7 +109,11 @@ export const MAX_EXAMINATIONS_PER_TICK = 5;
|
|
|
88
109
|
export interface WatchTickDeps {
|
|
89
110
|
/** Newest chats, most recent activity first */
|
|
90
111
|
listChats: () => Promise<ChatSummary[]>;
|
|
91
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* A window of the chat's most recent messages, newest first โ enough to find
|
|
114
|
+
* the newest one and whatever the user has not read yet (see
|
|
115
|
+
* `WATCH_MESSAGE_WINDOW`).
|
|
116
|
+
*/
|
|
92
117
|
listMessages: (chatId: string) => Promise<MessageSummary[]>;
|
|
93
118
|
/**
|
|
94
119
|
* The user's read cursor for one chat (`viewpoint.lastMessageReadDateTime`).
|
|
@@ -116,6 +141,8 @@ export interface WatchTickResult {
|
|
|
116
141
|
pending: number;
|
|
117
142
|
/** Of the examined ones, how many the user had already read */
|
|
118
143
|
alreadyRead: number;
|
|
144
|
+
/** Of the examined ones, how many were only postponed */
|
|
145
|
+
deferred: number;
|
|
119
146
|
wakes: WatchEvent[];
|
|
120
147
|
}
|
|
121
148
|
|
|
@@ -127,6 +154,12 @@ export interface WatchTickResult {
|
|
|
127
154
|
* without one every chat is compared against the user's own read state on the
|
|
128
155
|
* very first tick. What keeps that from becoming a flood is the per-tick cap
|
|
129
156
|
* and the hourly wake limit, not a window that quietly drops messages.
|
|
157
|
+
*
|
|
158
|
+
* The one rule the whole function hangs on: **the cursor is written only for a
|
|
159
|
+
* decision that is final.** It is the record of what pi has looked at, so what
|
|
160
|
+
* lands in it is never looked at again โ which is right for a message from
|
|
161
|
+
* somebody pi does not listen to, and wrong for a message that was merely too
|
|
162
|
+
* early. Those go to `noteDeferral` instead and keep their place in line.
|
|
130
163
|
*/
|
|
131
164
|
export async function runWatchTick(
|
|
132
165
|
deps: WatchTickDeps,
|
|
@@ -139,17 +172,22 @@ export async function runWatchTick(
|
|
|
139
172
|
|
|
140
173
|
const chats = (await deps.listChats()).filter((chat) => deps.allowed?.(chat) ?? true);
|
|
141
174
|
|
|
142
|
-
const candidates = chatsToExamine(state, chats, watch);
|
|
175
|
+
const candidates = chatsToExamine(state, chats, watch, now);
|
|
143
176
|
const batch = candidates.slice(0, MAX_EXAMINATIONS_PER_TICK);
|
|
144
177
|
|
|
145
178
|
const wakes: WatchEvent[] = [];
|
|
146
179
|
let alreadyRead = 0;
|
|
180
|
+
let deferred = 0;
|
|
147
181
|
|
|
148
182
|
for (const chat of batch) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
|
|
183
|
+
const marker = activityMarker(chat);
|
|
184
|
+
|
|
185
|
+
// A chat that cannot be read must not occupy a batch slot on every tick, and
|
|
186
|
+
// a token renewal must not turn into a message nobody ever answers: it is
|
|
187
|
+
// written off only once it has used up its attempts.
|
|
188
|
+
const failed = () => {
|
|
189
|
+
if (!noteFailure(state, chat.id)) settleChat(state, chat.id, marker);
|
|
190
|
+
};
|
|
153
191
|
|
|
154
192
|
// The read cursor first: a chat the user has already opened in Teams ends
|
|
155
193
|
// here, one Graph call in, before the message is even fetched.
|
|
@@ -157,12 +195,13 @@ export async function runWatchTick(
|
|
|
157
195
|
try {
|
|
158
196
|
readAt = await deps.readState(chat.id);
|
|
159
197
|
} catch {
|
|
160
|
-
|
|
198
|
+
failed();
|
|
161
199
|
continue;
|
|
162
200
|
}
|
|
163
201
|
|
|
164
202
|
if (!isUnread(chat, readAt)) {
|
|
165
203
|
noteSuccess(state, chat.id);
|
|
204
|
+
settleChat(state, chat.id, marker);
|
|
166
205
|
alreadyRead += 1;
|
|
167
206
|
continue;
|
|
168
207
|
}
|
|
@@ -173,27 +212,52 @@ export async function runWatchTick(
|
|
|
173
212
|
} catch {
|
|
174
213
|
// A single unreadable chat must not end the tick โ the rest of the
|
|
175
214
|
// list is still worth examining.
|
|
176
|
-
|
|
215
|
+
failed();
|
|
177
216
|
continue;
|
|
178
217
|
}
|
|
179
218
|
|
|
180
219
|
noteSuccess(state, chat.id);
|
|
181
220
|
|
|
182
221
|
const message = messages.find((entry) => !entry.deletedDateTime) ?? messages[0];
|
|
183
|
-
if (!message)
|
|
222
|
+
if (!message) {
|
|
223
|
+
settleChat(state, chat.id, marker);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
184
226
|
|
|
185
227
|
const decision = shouldWake(chat, message, me, watch, state, now);
|
|
186
|
-
if (!decision.wake)
|
|
228
|
+
if (!decision.wake) {
|
|
229
|
+
if (decision.retryAfterMs !== undefined) {
|
|
230
|
+
// Postponed, not answered and not written off: the chat keeps its
|
|
231
|
+
// message and waits out the cooldown or the wake limit.
|
|
232
|
+
noteDeferral(state, chat.id, now + decision.retryAfterMs);
|
|
233
|
+
deferred += 1;
|
|
234
|
+
} else {
|
|
235
|
+
settleChat(state, chat.id, marker);
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
187
239
|
|
|
188
240
|
noteWake(state, chat.id, now);
|
|
189
|
-
|
|
241
|
+
settleChat(state, chat.id, marker);
|
|
242
|
+
|
|
243
|
+
// The answer has to cover the whole open thread, not just its newest line:
|
|
244
|
+
// the trigger message is the reason for the wake, the backlog is the reason
|
|
245
|
+
// for a reply that sounds like the conversation was read.
|
|
246
|
+
const backlog = openMessages(messages, readAt, me);
|
|
247
|
+
wakes.push({
|
|
248
|
+
chat,
|
|
249
|
+
message,
|
|
250
|
+
backlog: backlog.length > 0 ? backlog : [message],
|
|
251
|
+
wokeAt: now,
|
|
252
|
+
me,
|
|
253
|
+
});
|
|
190
254
|
}
|
|
191
255
|
|
|
192
256
|
// Written even when nothing woke: marking a chat examined is the progress
|
|
193
|
-
// that must survive a restart.
|
|
257
|
+
// that must survive a restart. Postponed chats are absent from it on purpose.
|
|
194
258
|
if (batch.length > 0) deps.persistCursor?.(state.seen);
|
|
195
259
|
|
|
196
|
-
return { examined: batch.length, pending: candidates.length, alreadyRead, wakes };
|
|
260
|
+
return { examined: batch.length, pending: candidates.length, alreadyRead, deferred, wakes };
|
|
197
261
|
}
|
|
198
262
|
|
|
199
263
|
// ---------------------------------------------------------------------------
|
|
@@ -274,6 +338,7 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
|
|
|
274
338
|
intervalSeconds: watch.intervalSeconds,
|
|
275
339
|
wakesThisHour: 0,
|
|
276
340
|
trackedChats: 0,
|
|
341
|
+
waiting: 0,
|
|
277
342
|
};
|
|
278
343
|
|
|
279
344
|
const schedule = (delayMs: number) => {
|
|
@@ -295,7 +360,8 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
|
|
|
295
360
|
const result = await runWatchTick(
|
|
296
361
|
{
|
|
297
362
|
listChats: () => listChats(connection, { max: 50, meId: me?.id }),
|
|
298
|
-
listMessages: (chatId) =>
|
|
363
|
+
listMessages: (chatId) =>
|
|
364
|
+
listChatMessages(connection, chatId, { max: WATCH_MESSAGE_WINDOW }),
|
|
299
365
|
readState: async (chatId) => (await getChatViewpoint(connection, chatId))?.lastMessageReadAt,
|
|
300
366
|
now: () => Date.now(),
|
|
301
367
|
allowed: (chat) => hasAccess(connection, "read", "chats", chatCandidates(chat)),
|
|
@@ -318,6 +384,7 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
|
|
|
318
384
|
status.lastError = undefined;
|
|
319
385
|
status.wakesThisHour = wakesThisHour(state, status.lastTickAt);
|
|
320
386
|
status.trackedChats = state.seen.size;
|
|
387
|
+
status.waiting = waitingCount(state, status.lastTickAt);
|
|
321
388
|
|
|
322
389
|
for (const event of result.wakes) {
|
|
323
390
|
status.lastWakeAt = event.wokeAt;
|
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, " +
|