@patimweb/pi-teams 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -11
- package/docs/entra-app-registration.md +243 -0
- package/package.json +1 -1
- package/prompts/teams-schedule.md +23 -0
- package/screenshot.png +0 -0
- package/skills/teams-collaboration/SKILL.md +177 -2
- package/src/config/index.ts +145 -1
- package/src/extension/index.ts +186 -4
- package/src/graph/calendar.ts +87 -0
- package/src/graph/chats.ts +134 -3
- package/src/graph/mappers.ts +20 -0
- package/src/graph/messages.ts +64 -40
- package/src/safety/index.ts +17 -3
- package/src/status.ts +22 -1
- package/src/tools/shared.ts +24 -0
- package/src/tools/teams-availability.ts +224 -0
- package/src/tools/teams-chat-members.ts +148 -0
- package/src/tools/teams-delete-message.ts +21 -48
- package/src/tools/teams-inbox.ts +1 -0
- package/src/tools/teams-mark-read.ts +84 -0
- package/src/tools/teams-meetings.ts +98 -0
- package/src/tools/teams-send-channel-message.ts +7 -2
- package/src/tools/teams-send-chat-message.ts +7 -2
- package/src/tools/teams-update-message.ts +88 -0
- package/src/tools/teams-watch.ts +229 -0
- package/src/tools/tool-names.ts +5 -2
- package/src/types.ts +11 -0
- package/src/utils/errors.ts +1 -0
- package/src/utils/formatting.ts +62 -12
- package/src/utils/richtext.ts +150 -0
- package/src/utils/slots.ts +171 -0
- package/src/watch/index.ts +224 -0
- package/src/watch/loop.ts +304 -0
- package/src/watch/prompt.ts +59 -0
- package/src/tools/teams-create-channel.ts +0 -89
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Listen mode — the pure half.
|
|
3
|
+
*
|
|
4
|
+
* "Watching" Teams means answering one question over and over: *is there
|
|
5
|
+
* anything here that should wake pi up?* That question is decided entirely
|
|
6
|
+
* from data — the chat list, one message, and what we have already seen — so it
|
|
7
|
+
* lives here as pure functions. The polling, the Graph calls and the prompt
|
|
8
|
+
* injection are in ./loop.ts.
|
|
9
|
+
*
|
|
10
|
+
* Two things are deliberately not pure: the clock and the fetch. Both are
|
|
11
|
+
* injected, which is what makes the decision rules testable without a tenant.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ChatSummary, MessageSummary, PersonRef, SignedInUser } from "../types.ts";
|
|
15
|
+
import type { ResolvedWatchConfig } from "../config/index.ts";
|
|
16
|
+
import { matchesPattern } from "../config/scope.ts";
|
|
17
|
+
import { chatCandidates } from "../graph/mappers.ts";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// State
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What the watcher remembers between ticks.
|
|
25
|
+
*
|
|
26
|
+
* `seen` is keyed by chat and holds the activity marker of the last message we
|
|
27
|
+
* looked at, so a tick can skip every chat that has not moved. It is a
|
|
28
|
+
* high-water mark, not a read receipt: nothing here is ever sent to Teams.
|
|
29
|
+
*/
|
|
30
|
+
export interface WatchState {
|
|
31
|
+
/** chatId → activity marker of the last examined message */
|
|
32
|
+
seen: Map<string, string>;
|
|
33
|
+
/** chatId → epoch ms of the last wake in that chat */
|
|
34
|
+
lastTriggered: Map<string, number>;
|
|
35
|
+
/** epoch ms of every wake, pruned to the last hour */
|
|
36
|
+
triggers: number[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createWatchState(): WatchState {
|
|
40
|
+
return { seen: new Map(), lastTriggered: new Map(), triggers: [] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Marker for "this is the newest state of that chat we have looked at". */
|
|
44
|
+
export function activityMarker(chat: ChatSummary): string {
|
|
45
|
+
return chat.lastUpdated ?? "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How many wakes happened within the last hour. */
|
|
49
|
+
export function wakesThisHour(state: WatchState, now: number): number {
|
|
50
|
+
return state.triggers.filter((at) => now - at < 3600_000).length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Drop bookkeeping that can no longer influence a decision.
|
|
55
|
+
*
|
|
56
|
+
* A session can run for days; without this the two maps grow with every chat
|
|
57
|
+
* the user ever touches. The hour is the longest window any rule looks at.
|
|
58
|
+
*/
|
|
59
|
+
export function pruneState(state: WatchState, now: number): void {
|
|
60
|
+
state.triggers = state.triggers.filter((at) => now - at < 3600_000);
|
|
61
|
+
for (const [chatId, at] of state.lastTriggered) {
|
|
62
|
+
if (now - at > 24 * 3600_000) state.lastTriggered.delete(chatId);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Which chats are worth a look
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Chats that are both allowed by the watch filter and have moved since we last
|
|
72
|
+
* looked.
|
|
73
|
+
*
|
|
74
|
+
* @param freshnessMs How far back "new" reaches. Enabling listen mode must not
|
|
75
|
+
* wake pi for every conversation that happened to be active this morning, so
|
|
76
|
+
* anything older than the window is recorded as seen and then ignored.
|
|
77
|
+
*/
|
|
78
|
+
export function chatsToExamine(
|
|
79
|
+
state: WatchState,
|
|
80
|
+
chats: ChatSummary[],
|
|
81
|
+
watch: ResolvedWatchConfig,
|
|
82
|
+
now: number,
|
|
83
|
+
freshnessMs: number,
|
|
84
|
+
): ChatSummary[] {
|
|
85
|
+
const cutoff = now - freshnessMs;
|
|
86
|
+
|
|
87
|
+
return chats.filter((chat) => {
|
|
88
|
+
if (!isWatchedChat(chat, watch)) return false;
|
|
89
|
+
if (state.seen.get(chat.id) === activityMarker(chat)) return false;
|
|
90
|
+
|
|
91
|
+
const updated = chat.lastUpdated ? new Date(chat.lastUpdated).getTime() : 0;
|
|
92
|
+
// A chat with no activity timestamp cannot be dated, so it is never new.
|
|
93
|
+
if (!updated || updated < cutoff) return false;
|
|
94
|
+
|
|
95
|
+
return true;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Does this chat fall inside the configured chat filter? */
|
|
100
|
+
export function isWatchedChat(chat: ChatSummary, watch: ResolvedWatchConfig): boolean {
|
|
101
|
+
if (watch.chats.length === 0) return true;
|
|
102
|
+
const candidates = chatCandidates(chat).filter((value): value is string => !!value);
|
|
103
|
+
return watch.chats.some((pattern) => candidates.some((candidate) => matchesPattern(pattern, candidate)));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Who pi listens to
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Does the sender fall inside the configured listen list?
|
|
112
|
+
*
|
|
113
|
+
* `chats` says where pi listens, this says to whom — the person filter is what
|
|
114
|
+
* keeps listen mode from becoming "answer the whole company". Matched against
|
|
115
|
+
* every identifier the message carries, like the scope rules do.
|
|
116
|
+
*/
|
|
117
|
+
export function isWatchedSender(message: MessageSummary, watch: ResolvedWatchConfig): boolean {
|
|
118
|
+
if (watch.from.length === 0) return true;
|
|
119
|
+
|
|
120
|
+
const sender = message.from;
|
|
121
|
+
if (!sender) return false;
|
|
122
|
+
|
|
123
|
+
const candidates = [sender.displayName, sender.upn, sender.mail, sender.id].filter(
|
|
124
|
+
(value): value is string => !!value,
|
|
125
|
+
);
|
|
126
|
+
return watch.from.some((pattern) => candidates.some((candidate) => matchesPattern(pattern, candidate)));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Whether a message should wake pi
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
export type WakeDecision = { wake: true } | { wake: false; reason: string };
|
|
134
|
+
|
|
135
|
+
const WAKE: WakeDecision = { wake: true };
|
|
136
|
+
const skip = (reason: string): WakeDecision => ({ wake: false, reason });
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Should this message wake pi?
|
|
140
|
+
*
|
|
141
|
+
* Ordered so the cheapest and most obvious reason to stay quiet is also the
|
|
142
|
+
* one that gets reported — a wrong wake costs a model turn, a wrong silence
|
|
143
|
+
* costs nothing but the reason in the log.
|
|
144
|
+
*/
|
|
145
|
+
export function shouldWake(
|
|
146
|
+
chat: ChatSummary,
|
|
147
|
+
message: MessageSummary,
|
|
148
|
+
me: SignedInUser | undefined,
|
|
149
|
+
watch: ResolvedWatchConfig,
|
|
150
|
+
state: WatchState,
|
|
151
|
+
now: number,
|
|
152
|
+
): WakeDecision {
|
|
153
|
+
if (message.deletedDateTime) return skip("the message was deleted");
|
|
154
|
+
if (isFromMe(message.from, me)) return skip("the last message is my own");
|
|
155
|
+
|
|
156
|
+
if (!isWatchedChat(chat, watch)) return skip("the chat is outside the configured watch list");
|
|
157
|
+
if (!isWatchedSender(message, watch)) return skip("the sender is outside the configured listen list");
|
|
158
|
+
if (watch.mentionOnly && !mentionsMe(message, me)) return skip("the chat is set to mentions only");
|
|
159
|
+
if (watch.maxTriggersPerHour > 0 && wakesThisHour(state, now) >= watch.maxTriggersPerHour) {
|
|
160
|
+
return skip("the hourly wake limit is reached");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const last = state.lastTriggered.get(chat.id);
|
|
164
|
+
if (last !== undefined && now - last < watch.cooldownSeconds * 1000) {
|
|
165
|
+
return skip("this chat is inside its cooldown");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return WAKE;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Record that pi was woken for a chat.
|
|
173
|
+
*
|
|
174
|
+
* Split from the decision so `shouldWake` stays free of side effects and can be
|
|
175
|
+
* called speculatively.
|
|
176
|
+
*/
|
|
177
|
+
export function noteWake(state: WatchState, chatId: string, now: number): void {
|
|
178
|
+
state.lastTriggered.set(chatId, now);
|
|
179
|
+
state.triggers.push(now);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Was this message sent by the signed-in user?
|
|
184
|
+
*
|
|
185
|
+
* pi posts as the user, so its own replies come back as "my own message" on the
|
|
186
|
+
* next tick — which is what stops the watcher from waking on itself. Matching
|
|
187
|
+
* on the ID first and the display name second covers both a chat member entry
|
|
188
|
+
* that carries an ID and one that only carries a name.
|
|
189
|
+
*/
|
|
190
|
+
export function isFromMe(from: PersonRef | undefined, me: SignedInUser | undefined): boolean {
|
|
191
|
+
if (!from || !me) return false;
|
|
192
|
+
if (from.id && me.id && from.id === me.id) return true;
|
|
193
|
+
return !!from.displayName && from.displayName === me.displayName;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Does this message mention the signed-in user?
|
|
198
|
+
*
|
|
199
|
+
* Graph fills `mentions` for real @-mentions; the text fallback covers the
|
|
200
|
+
* common case of someone typing a first name, which is what "mentions only"
|
|
201
|
+
* means to a person waiting for an answer.
|
|
202
|
+
*/
|
|
203
|
+
export function mentionsMe(message: MessageSummary, me: SignedInUser | undefined): boolean {
|
|
204
|
+
if (!me) return false;
|
|
205
|
+
|
|
206
|
+
const mentioned = message.mentions.some(
|
|
207
|
+
(person) =>
|
|
208
|
+
(person.id && person.id === me.id) ||
|
|
209
|
+
(person.displayName && person.displayName === me.displayName),
|
|
210
|
+
);
|
|
211
|
+
if (mentioned) return true;
|
|
212
|
+
|
|
213
|
+
const text = message.text ?? "";
|
|
214
|
+
if (me.displayName && text.includes(`@${me.displayName}`)) return true;
|
|
215
|
+
|
|
216
|
+
// First name only: "Patrick, kannst du…". Word-boundary-ish, case-insensitive.
|
|
217
|
+
const firstName = me.displayName.split(/\s+/)[0];
|
|
218
|
+
if (!firstName || firstName.length < 3) return false;
|
|
219
|
+
return new RegExp(`(^|[^\\p{L}])${escapeRegExp(firstName)}($|[^\\p{L}])`, "iu").test(text);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function escapeRegExp(value: string): string {
|
|
223
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
224
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Listen mode — the loop.
|
|
3
|
+
*
|
|
4
|
+
* Polls the signed-in user's chats and reports the ones that should wake pi.
|
|
5
|
+
* Deliberately knows nothing about pi: it takes a callback, so the decision
|
|
6
|
+
* rules (./index.ts) and the polling can each be tested on their own, and the
|
|
7
|
+
* extension stays the only place that touches the session.
|
|
8
|
+
*
|
|
9
|
+
* Why polling and not a webhook: Microsoft Graph change notifications for chats
|
|
10
|
+
* need a publicly reachable HTTPS endpoint plus subscription renewal, which a
|
|
11
|
+
* laptop on a home network cannot offer. Polling costs one `/me/chats` call per
|
|
12
|
+
* interval and works everywhere.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ChatSummary, MessageSummary, SignedInUser } from "../types.ts";
|
|
16
|
+
import type { ResolvedWatchConfig, TeamsConnection } from "../config/index.ts";
|
|
17
|
+
import { listChats } from "../graph/chats.ts";
|
|
18
|
+
import { listChatMessages } from "../graph/messages.ts";
|
|
19
|
+
import { getMe } from "../graph/me.ts";
|
|
20
|
+
import { chatCandidates } from "../graph/mappers.ts";
|
|
21
|
+
import { hasAccess } from "../safety/index.ts";
|
|
22
|
+
import { formatGraphError } from "../utils/errors.ts";
|
|
23
|
+
import {
|
|
24
|
+
activityMarker,
|
|
25
|
+
chatsToExamine,
|
|
26
|
+
createWatchState,
|
|
27
|
+
noteWake,
|
|
28
|
+
pruneState,
|
|
29
|
+
shouldWake,
|
|
30
|
+
wakesThisHour,
|
|
31
|
+
type WatchState,
|
|
32
|
+
} from "./index.ts";
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Events and status
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/** One message that deserves a model turn. */
|
|
39
|
+
export interface WatchEvent {
|
|
40
|
+
chat: ChatSummary;
|
|
41
|
+
message: MessageSummary;
|
|
42
|
+
/** Epoch ms the wake was decided */
|
|
43
|
+
wokeAt: number;
|
|
44
|
+
/** Who pi is acting as, carried along for the prompt */
|
|
45
|
+
me?: SignedInUser;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the footer and `teams_watch` report about a running watcher. */
|
|
49
|
+
export interface WatchRuntimeStatus {
|
|
50
|
+
running: boolean;
|
|
51
|
+
enabled: boolean;
|
|
52
|
+
intervalSeconds: number;
|
|
53
|
+
lastTickAt?: number;
|
|
54
|
+
lastWakeAt?: number;
|
|
55
|
+
/** Chat label of the last wake, for the status line */
|
|
56
|
+
lastWakeChat?: string;
|
|
57
|
+
lastError?: string;
|
|
58
|
+
wakesThisHour: number;
|
|
59
|
+
/** Chats seen so far — a quick sanity signal that the loop is alive */
|
|
60
|
+
trackedChats: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// One tick
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Bound on how many chats one tick examines.
|
|
69
|
+
*
|
|
70
|
+
* A chat list can move a lot at once — a Monday morning, a release — and each
|
|
71
|
+
* examination is a Graph call. The cap keeps a burst from turning into a
|
|
72
|
+
* thundering herd; whatever is left over is simply picked up next tick.
|
|
73
|
+
*/
|
|
74
|
+
export const MAX_EXAMINATIONS_PER_TICK = 5;
|
|
75
|
+
|
|
76
|
+
export interface WatchTickDeps {
|
|
77
|
+
/** Newest chats, most recent activity first */
|
|
78
|
+
listChats: () => Promise<ChatSummary[]>;
|
|
79
|
+
/** The most recent messages of one chat, newest first */
|
|
80
|
+
listMessages: (chatId: string) => Promise<MessageSummary[]>;
|
|
81
|
+
now: () => number;
|
|
82
|
+
/**
|
|
83
|
+
* Read guard, so the watcher respects the same allow/deny rules as every
|
|
84
|
+
* other read. Defaults to allowing everything.
|
|
85
|
+
*/
|
|
86
|
+
allowed?: (chat: ChatSummary) => boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface WatchTickResult {
|
|
90
|
+
/** Chats looked at in this tick */
|
|
91
|
+
examined: number;
|
|
92
|
+
wakes: WatchEvent[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Advance the watcher by one tick.
|
|
97
|
+
*
|
|
98
|
+
* The first tick is a **baseline**: it records what is currently there and
|
|
99
|
+
* wakes nobody. Switching listen mode on must not answer the backlog — it means
|
|
100
|
+
* "from now on", and anything older than the freshness window would be a
|
|
101
|
+
* surprise to wake for.
|
|
102
|
+
*/
|
|
103
|
+
export async function runWatchTick(
|
|
104
|
+
deps: WatchTickDeps,
|
|
105
|
+
state: WatchState,
|
|
106
|
+
watch: ResolvedWatchConfig,
|
|
107
|
+
me: SignedInUser | undefined,
|
|
108
|
+
options: { baseline: boolean },
|
|
109
|
+
): Promise<WatchTickResult> {
|
|
110
|
+
const now = deps.now();
|
|
111
|
+
pruneState(state, now);
|
|
112
|
+
|
|
113
|
+
const chats = (await deps.listChats()).filter((chat) => deps.allowed?.(chat) ?? true);
|
|
114
|
+
|
|
115
|
+
if (options.baseline) {
|
|
116
|
+
for (const chat of chats) state.seen.set(chat.id, activityMarker(chat));
|
|
117
|
+
return { examined: 0, wakes: [] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const freshnessMs = Math.max(5 * 60_000, watch.intervalSeconds * 2000);
|
|
121
|
+
const candidates = chatsToExamine(state, chats, watch, now, freshnessMs).slice(0, MAX_EXAMINATIONS_PER_TICK);
|
|
122
|
+
|
|
123
|
+
const wakes: WatchEvent[] = [];
|
|
124
|
+
|
|
125
|
+
for (const chat of candidates) {
|
|
126
|
+
// Mark first: a chat that fails to load must not be retried forever, and
|
|
127
|
+
// the marker is the chat list's own, so the next tick sees it as moved
|
|
128
|
+
// only if something new actually arrives.
|
|
129
|
+
state.seen.set(chat.id, activityMarker(chat));
|
|
130
|
+
|
|
131
|
+
let messages: MessageSummary[];
|
|
132
|
+
try {
|
|
133
|
+
messages = await deps.listMessages(chat.id);
|
|
134
|
+
} catch {
|
|
135
|
+
// A single unreadable chat must not end the tick — the rest of the
|
|
136
|
+
// list is still worth examining.
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const message = messages.find((entry) => !entry.deletedDateTime) ?? messages[0];
|
|
141
|
+
if (!message) continue;
|
|
142
|
+
|
|
143
|
+
const decision = shouldWake(chat, message, me, watch, state, now);
|
|
144
|
+
if (!decision.wake) continue;
|
|
145
|
+
|
|
146
|
+
noteWake(state, chat.id, now);
|
|
147
|
+
wakes.push({ chat, message, wokeAt: now, me });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { examined: candidates.length, wakes };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// The loop
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
export interface WatchLoopOptions {
|
|
158
|
+
connection: TeamsConnection;
|
|
159
|
+
/** Called once per wake, in arrival order */
|
|
160
|
+
onWake: (event: WatchEvent) => void;
|
|
161
|
+
/** Called when a tick fails; the loop keeps running */
|
|
162
|
+
onError?: (message: string) => void;
|
|
163
|
+
/** Called after every tick, for the footer */
|
|
164
|
+
onTick?: (status: WatchRuntimeStatus) => void;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface WatchLoop {
|
|
168
|
+
/** Stop polling. Safe to call twice. */
|
|
169
|
+
stop(): void;
|
|
170
|
+
/** A snapshot for the status line and the tool */
|
|
171
|
+
status(): WatchRuntimeStatus;
|
|
172
|
+
/** Force a tick now, instead of waiting out the interval */
|
|
173
|
+
poll(): Promise<void>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The loop this process is running, if any.
|
|
178
|
+
*
|
|
179
|
+
* One session runs one watcher, and the tools that report on listen mode need
|
|
180
|
+
* to see it. Keeping the slot here — rather than in the extension — means
|
|
181
|
+
* `teams_watch` can answer with live runtime facts without importing the
|
|
182
|
+
* extension, which imports the tools.
|
|
183
|
+
*/
|
|
184
|
+
let activeLoop: WatchLoop | undefined;
|
|
185
|
+
|
|
186
|
+
export function setActiveWatchLoop(loop: WatchLoop | undefined): void {
|
|
187
|
+
activeLoop = loop;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function getActiveWatchLoop(): WatchLoop | undefined {
|
|
191
|
+
return activeLoop;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Stop the running watcher, if there is one. */
|
|
195
|
+
export function stopActiveWatchLoop(): void {
|
|
196
|
+
activeLoop?.stop();
|
|
197
|
+
activeLoop = undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Start polling for the given connection.
|
|
202
|
+
*
|
|
203
|
+
* Each tick is scheduled after the previous one finished rather than on a fixed
|
|
204
|
+
* timer, so a slow Graph call cannot stack ticks on top of each other.
|
|
205
|
+
*/
|
|
206
|
+
export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
|
|
207
|
+
const { connection } = options;
|
|
208
|
+
const watch = connection.watch;
|
|
209
|
+
const state = createWatchState();
|
|
210
|
+
|
|
211
|
+
let stopped = false;
|
|
212
|
+
let baselineDone = false;
|
|
213
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
214
|
+
let me: SignedInUser | undefined;
|
|
215
|
+
let meResolved = false;
|
|
216
|
+
let failureCount = 0;
|
|
217
|
+
|
|
218
|
+
const status: WatchRuntimeStatus = {
|
|
219
|
+
running: true,
|
|
220
|
+
enabled: watch.enabled,
|
|
221
|
+
intervalSeconds: watch.intervalSeconds,
|
|
222
|
+
wakesThisHour: 0,
|
|
223
|
+
trackedChats: 0,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const schedule = (delayMs: number) => {
|
|
227
|
+
if (stopped) return;
|
|
228
|
+
timer = setTimeout(() => void tick(), delayMs);
|
|
229
|
+
// Do not hold the process open for a poll that can wait.
|
|
230
|
+
timer.unref?.();
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
async function tick(): Promise<void> {
|
|
234
|
+
if (stopped) return;
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
if (!meResolved) {
|
|
238
|
+
me = await getMe(connection).catch(() => undefined);
|
|
239
|
+
meResolved = true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const result = await runWatchTick(
|
|
243
|
+
{
|
|
244
|
+
listChats: () => listChats(connection, { max: 50, meId: me?.id }),
|
|
245
|
+
listMessages: (chatId) => listChatMessages(connection, chatId, { max: 1 }),
|
|
246
|
+
now: () => Date.now(),
|
|
247
|
+
allowed: (chat) => hasAccess(connection, "read", "chats", chatCandidates(chat)),
|
|
248
|
+
},
|
|
249
|
+
state,
|
|
250
|
+
watch,
|
|
251
|
+
me,
|
|
252
|
+
{ baseline: !baselineDone },
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
baselineDone = true;
|
|
256
|
+
failureCount = 0;
|
|
257
|
+
status.lastTickAt = Date.now();
|
|
258
|
+
status.lastError = undefined;
|
|
259
|
+
status.wakesThisHour = wakesThisHour(state, status.lastTickAt);
|
|
260
|
+
status.trackedChats = state.seen.size;
|
|
261
|
+
|
|
262
|
+
for (const event of result.wakes) {
|
|
263
|
+
status.lastWakeAt = event.wokeAt;
|
|
264
|
+
status.lastWakeChat = event.chat.label;
|
|
265
|
+
status.wakesThisHour = wakesThisHour(state, event.wokeAt);
|
|
266
|
+
try {
|
|
267
|
+
options.onWake(event);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
options.onError?.(formatGraphError(err));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
// Transient failures are normal — a sleeping laptop, an expired token,
|
|
274
|
+
// a throttled tenant. Back off instead of giving up, and report it so
|
|
275
|
+
// the user is not left wondering why nothing happens.
|
|
276
|
+
failureCount += 1;
|
|
277
|
+
status.lastTickAt = Date.now();
|
|
278
|
+
status.lastError = formatGraphError(err);
|
|
279
|
+
options.onError?.(status.lastError);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
options.onTick?.(status);
|
|
283
|
+
schedule(watch.intervalSeconds * 1000 * Math.min(2 ** failureCount, 10));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The first tick is immediate: it establishes the baseline, and a user who
|
|
287
|
+
// just switched listen mode should not wait a full interval to see it alive.
|
|
288
|
+
void tick();
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
stop() {
|
|
292
|
+
stopped = true;
|
|
293
|
+
status.running = false;
|
|
294
|
+
if (timer) clearTimeout(timer);
|
|
295
|
+
timer = undefined;
|
|
296
|
+
},
|
|
297
|
+
status: () => ({ ...status, wakesThisHour: wakesThisHour(state, Date.now()) }),
|
|
298
|
+
async poll() {
|
|
299
|
+
if (timer) clearTimeout(timer);
|
|
300
|
+
timer = undefined;
|
|
301
|
+
await tick();
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Listen mode — the prompt.
|
|
3
|
+
*
|
|
4
|
+
* An incoming Teams message is turned into a prompt, and this is the whole of
|
|
5
|
+
* that translation. It is a separate module because the wording is the feature:
|
|
6
|
+
* what pi does with a message it was woken for is decided here, and nothing in
|
|
7
|
+
* the polling loop should be able to change it.
|
|
8
|
+
*
|
|
9
|
+
* Two things the prompt must always carry: the chat ID — so the reply cannot
|
|
10
|
+
* land in the wrong conversation, since pi posts as a person and a name is not
|
|
11
|
+
* an identifier — and the instruction that doing nothing is a valid answer.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { MessageSummary, SignedInUser } from "../types.ts";
|
|
15
|
+
import type { WatchEvent } from "./loop.ts";
|
|
16
|
+
|
|
17
|
+
function senderName(message: MessageSummary): string {
|
|
18
|
+
return message.from?.displayName ?? message.from?.upn ?? "someone";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The prompt for one wake.
|
|
23
|
+
*
|
|
24
|
+
* Written to be read by the model, not by the user: it states the facts, names
|
|
25
|
+
* the tool to use, and then gets out of the way.
|
|
26
|
+
*/
|
|
27
|
+
export function composeWatchPrompt(event: WatchEvent, me?: SignedInUser): string {
|
|
28
|
+
const { chat, message } = event;
|
|
29
|
+
const text = message.text?.trim() ? message.text.trim() : "(no text content — open the chat to see it)";
|
|
30
|
+
const at = message.createdDateTime ? new Date(message.createdDateTime).toLocaleString() : "just now";
|
|
31
|
+
|
|
32
|
+
return [
|
|
33
|
+
"An incoming Microsoft Teams message arrived while you were idle.",
|
|
34
|
+
"",
|
|
35
|
+
`- Chat: ${chat.label} (${chat.chatType})`,
|
|
36
|
+
`- From: ${senderName(message)}`,
|
|
37
|
+
`- At: ${at}`,
|
|
38
|
+
`- Chat ID: ${chat.id}`,
|
|
39
|
+
"",
|
|
40
|
+
"Message:",
|
|
41
|
+
text
|
|
42
|
+
.split("\n")
|
|
43
|
+
.map((line) => `> ${line}`)
|
|
44
|
+
.join("\n"),
|
|
45
|
+
"",
|
|
46
|
+
"Decide whether this needs an answer.",
|
|
47
|
+
"",
|
|
48
|
+
`- If it does: reply with teams_send_chat_message, passing chat: "${chat.id}". pi posts as ` +
|
|
49
|
+
`${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.",
|
|
51
|
+
"- If it does not: say so in one line and stop.",
|
|
52
|
+
"",
|
|
53
|
+
"Format it for a chat bubble, not a document: the answer first, three short paragraphs at most, " +
|
|
54
|
+
"bullets for anything enumerable, no tables. The recipient is not expecting this message, so it has " +
|
|
55
|
+
"to be readable at a glance.",
|
|
56
|
+
"",
|
|
57
|
+
"Do not react, do not touch other chats, and do not send anything to a channel.",
|
|
58
|
+
].join("\n");
|
|
59
|
+
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* teams_create_channel — add a channel to a team.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { Type } from "typebox";
|
|
6
|
-
import { createChannel } from "../graph/teams.ts";
|
|
7
|
-
import { auditWrite } from "../safety/audit.ts";
|
|
8
|
-
import { requireTeam } from "./resolve.ts";
|
|
9
|
-
import {
|
|
10
|
-
AccountParam,
|
|
11
|
-
TenantParam,
|
|
12
|
-
connectionFor,
|
|
13
|
-
currentUser,
|
|
14
|
-
run,
|
|
15
|
-
textResult,
|
|
16
|
-
type ToolContext,
|
|
17
|
-
type ToolResult,
|
|
18
|
-
} from "./shared.ts";
|
|
19
|
-
|
|
20
|
-
export const teamsCreateChannelTool = {
|
|
21
|
-
name: "teams_create_channel",
|
|
22
|
-
description:
|
|
23
|
-
"Create a new channel in a Microsoft Teams team. membershipType 'standard' (default) is visible to the " +
|
|
24
|
-
"whole team, 'private' only to the members you add afterwards.",
|
|
25
|
-
parameters: Type.Object({
|
|
26
|
-
team: Type.String({ description: "Team name or ID" }),
|
|
27
|
-
name: Type.String({ description: "Channel display name" }),
|
|
28
|
-
description: Type.Optional(Type.String({ description: "Channel description" })),
|
|
29
|
-
membershipType: Type.Optional(
|
|
30
|
-
Type.String({ description: "'standard' (default) or 'private'" }),
|
|
31
|
-
),
|
|
32
|
-
account: AccountParam,
|
|
33
|
-
tenant: TenantParam,
|
|
34
|
-
}),
|
|
35
|
-
promptSnippet: "Create a channel in a Teams team",
|
|
36
|
-
|
|
37
|
-
async execute(
|
|
38
|
-
_toolCallId: string,
|
|
39
|
-
params: {
|
|
40
|
-
team: string;
|
|
41
|
-
name: string;
|
|
42
|
-
description?: string;
|
|
43
|
-
membershipType?: string;
|
|
44
|
-
account?: string;
|
|
45
|
-
tenant?: string;
|
|
46
|
-
},
|
|
47
|
-
signal: AbortSignal | undefined,
|
|
48
|
-
_onUpdate: undefined,
|
|
49
|
-
ctx: ToolContext,
|
|
50
|
-
): Promise<ToolResult> {
|
|
51
|
-
return run(async () => {
|
|
52
|
-
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
53
|
-
const team = await requireTeam(conn, params.team, "write", signal);
|
|
54
|
-
|
|
55
|
-
const channel = await createChannel(
|
|
56
|
-
conn,
|
|
57
|
-
team,
|
|
58
|
-
{
|
|
59
|
-
displayName: params.name,
|
|
60
|
-
description: params.description,
|
|
61
|
-
membershipType: params.membershipType,
|
|
62
|
-
},
|
|
63
|
-
signal,
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
const me = await currentUser(conn, signal).catch(() => undefined);
|
|
67
|
-
auditWrite(conn.audit, {
|
|
68
|
-
tool: "teams_create_channel",
|
|
69
|
-
account: conn.account,
|
|
70
|
-
tenant: conn.tenant,
|
|
71
|
-
actor: me?.upn,
|
|
72
|
-
target: `team:${team.displayName}`,
|
|
73
|
-
summary: `created channel "${params.name}" (${params.membershipType ?? "standard"})`,
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
return textResult(
|
|
77
|
-
[
|
|
78
|
-
`✅ Channel **${team.displayName}/${channel.displayName}** created.`,
|
|
79
|
-
"",
|
|
80
|
-
`channelId: ${channel.id}`,
|
|
81
|
-
channel.webUrl ? `Open in Teams: ${channel.webUrl}` : "",
|
|
82
|
-
]
|
|
83
|
-
.filter(Boolean)
|
|
84
|
-
.join("\n"),
|
|
85
|
-
{ teamId: team.id, channelId: channel.id },
|
|
86
|
-
);
|
|
87
|
-
});
|
|
88
|
-
},
|
|
89
|
-
};
|