@patimweb/pi-teams 1.1.0 → 1.2.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 +120 -12
- package/docs/entra-app-registration.md +218 -0
- package/package.json +1 -1
- package/skills/teams-collaboration/SKILL.md +87 -1
- package/src/config/index.ts +145 -1
- package/src/extension/index.ts +176 -2
- package/src/graph/messages.ts +14 -4
- package/src/safety/index.ts +1 -1
- package/src/status.ts +22 -1
- package/src/tools/teams-send-channel-message.ts +7 -2
- package/src/tools/teams-send-chat-message.ts +7 -2
- package/src/tools/teams-watch.ts +229 -0
- package/src/tools/tool-names.ts +1 -0
- package/src/utils/formatting.ts +0 -12
- package/src/utils/richtext.ts +150 -0
- package/src/watch/index.ts +224 -0
- package/src/watch/loop.ts +304 -0
- package/src/watch/prompt.ts +59 -0
|
@@ -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
|
+
}
|