@patimweb/pi-teams 1.8.0 → 1.9.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 +54 -654
- package/docs/architecture.md +59 -0
- package/docs/configuration.md +187 -0
- package/docs/entra-app-registration.md +16 -2
- package/docs/listen-mode.md +123 -0
- package/docs/security.md +116 -0
- package/docs/sign-in.md +110 -0
- package/docs/tools.md +150 -0
- package/docs/troubleshooting.md +55 -0
- package/package.json +1 -1
- package/skills/teams-collaboration/SKILL.md +23 -0
- package/src/config/index.ts +12 -0
- package/src/extension/index.ts +16 -0
- package/src/graph/client.ts +55 -1
- package/src/graph/media.ts +46 -2
- package/src/tools/teams-download-files.ts +47 -3
- package/src/utils/errors.ts +29 -0
- package/src/watch/loop.ts +69 -9
- package/src/watch/pin.ts +109 -0
package/src/watch/loop.ts
CHANGED
|
@@ -97,6 +97,18 @@ export interface WatchRuntimeStatus {
|
|
|
97
97
|
// One tick
|
|
98
98
|
// ---------------------------------------------------------------------------
|
|
99
99
|
|
|
100
|
+
/** A tick was asked to run without knowing who pi is signed in as. */
|
|
101
|
+
export class WatchIdentityError extends Error {
|
|
102
|
+
constructor(cause?: unknown) {
|
|
103
|
+
super(
|
|
104
|
+
"Listen mode is waiting: the signed-in user could not be determined, so pi's own replies " +
|
|
105
|
+
"could not be told apart from new messages. Retrying on the next poll." +
|
|
106
|
+
(cause ? ` ${formatGraphError(cause)}` : ""),
|
|
107
|
+
);
|
|
108
|
+
this.name = "WatchIdentityError";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
100
112
|
/**
|
|
101
113
|
* Bound on how many chats one tick examines.
|
|
102
114
|
*
|
|
@@ -165,8 +177,14 @@ export async function runWatchTick(
|
|
|
165
177
|
deps: WatchTickDeps,
|
|
166
178
|
state: WatchState,
|
|
167
179
|
watch: ResolvedWatchConfig,
|
|
168
|
-
me: SignedInUser
|
|
180
|
+
me: SignedInUser,
|
|
169
181
|
): Promise<WatchTickResult> {
|
|
182
|
+
// Who pi acts as is what tells its own replies apart from everybody else's
|
|
183
|
+
// (`isFromMe`). Without it, the answer pi just sent looks like a new message
|
|
184
|
+
// from a colleague and wakes pi again — a loop that answers itself. So a tick
|
|
185
|
+
// without an identity examines nothing, and in particular writes no cursor.
|
|
186
|
+
if (!me) throw new WatchIdentityError();
|
|
187
|
+
|
|
170
188
|
const now = deps.now();
|
|
171
189
|
pruneState(state, now);
|
|
172
190
|
|
|
@@ -328,8 +346,24 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
|
|
|
328
346
|
|
|
329
347
|
let stopped = false;
|
|
330
348
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
349
|
+
/**
|
|
350
|
+
* The tick currently running, if any.
|
|
351
|
+
*
|
|
352
|
+
* Every tick schedules the next one when it finishes, so two ticks running at
|
|
353
|
+
* once would leave two chains behind and quietly double the polling rate for
|
|
354
|
+
* the rest of the session. `poll()` therefore joins the tick in flight
|
|
355
|
+
* instead of starting a second one.
|
|
356
|
+
*/
|
|
357
|
+
let inFlight: Promise<void> | undefined;
|
|
358
|
+
/**
|
|
359
|
+
* Who pi acts as, looked up on the first tick that can reach Graph.
|
|
360
|
+
*
|
|
361
|
+
* A failed lookup is not an answer: it is retried on every tick until it
|
|
362
|
+
* succeeds, and no tick runs before it has. A watcher started before sign-in
|
|
363
|
+
* (autoStart in a fresh container, say) would otherwise settle for "nobody"
|
|
364
|
+
* for the whole session and wake on its own replies.
|
|
365
|
+
*/
|
|
331
366
|
let me: SignedInUser | undefined;
|
|
332
|
-
let meResolved = false;
|
|
333
367
|
let failureCount = 0;
|
|
334
368
|
|
|
335
369
|
const status: WatchRuntimeStatus = {
|
|
@@ -343,7 +377,7 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
|
|
|
343
377
|
|
|
344
378
|
const schedule = (delayMs: number) => {
|
|
345
379
|
if (stopped) return;
|
|
346
|
-
timer = setTimeout(() => void
|
|
380
|
+
timer = setTimeout(() => void runTick(), delayMs);
|
|
347
381
|
// Do not hold the process open for a poll that can wait.
|
|
348
382
|
timer.unref?.();
|
|
349
383
|
};
|
|
@@ -352,9 +386,12 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
|
|
|
352
386
|
if (stopped) return;
|
|
353
387
|
|
|
354
388
|
try {
|
|
355
|
-
if (!
|
|
356
|
-
|
|
357
|
-
|
|
389
|
+
if (!me) {
|
|
390
|
+
try {
|
|
391
|
+
me = await getMe(connection);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
throw new WatchIdentityError(err);
|
|
394
|
+
}
|
|
358
395
|
}
|
|
359
396
|
|
|
360
397
|
const result = await runWatchTick(
|
|
@@ -406,14 +443,31 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
|
|
|
406
443
|
options.onError?.(status.lastError);
|
|
407
444
|
}
|
|
408
445
|
|
|
409
|
-
|
|
446
|
+
// Outside the try above on purpose, and guarded on its own: a throwing
|
|
447
|
+
// status callback must not become an unhandled rejection that takes the
|
|
448
|
+
// watcher down without a word.
|
|
449
|
+
try {
|
|
450
|
+
options.onTick?.(status);
|
|
451
|
+
} catch {
|
|
452
|
+
/* a footer that cannot render is not worth stopping the loop for */
|
|
453
|
+
}
|
|
454
|
+
|
|
410
455
|
schedule(watch.intervalSeconds * 1000 * Math.min(2 ** failureCount, 10));
|
|
411
456
|
}
|
|
412
457
|
|
|
458
|
+
/** Run a tick unless one is already running; either way, resolve with it. */
|
|
459
|
+
function runTick(): Promise<void> {
|
|
460
|
+
if (inFlight) return inFlight;
|
|
461
|
+
inFlight = tick().finally(() => {
|
|
462
|
+
inFlight = undefined;
|
|
463
|
+
});
|
|
464
|
+
return inFlight;
|
|
465
|
+
}
|
|
466
|
+
|
|
413
467
|
// The first tick is immediate: it answers what is still open since the last
|
|
414
468
|
// run, and a user who switched listen mode on should not wait a full interval
|
|
415
469
|
// to see it alive.
|
|
416
|
-
void
|
|
470
|
+
void runTick();
|
|
417
471
|
|
|
418
472
|
return {
|
|
419
473
|
stop() {
|
|
@@ -424,9 +478,15 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
|
|
|
424
478
|
},
|
|
425
479
|
status: () => ({ ...status, wakesThisHour: wakesThisHour(state, Date.now()) }),
|
|
426
480
|
async poll() {
|
|
481
|
+
// Join a tick already in flight rather than racing it; only start one
|
|
482
|
+
// when nothing is running.
|
|
483
|
+
if (inFlight) {
|
|
484
|
+
await inFlight;
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
427
487
|
if (timer) clearTimeout(timer);
|
|
428
488
|
timer = undefined;
|
|
429
|
-
await
|
|
489
|
+
await runTick();
|
|
430
490
|
},
|
|
431
491
|
};
|
|
432
492
|
}
|
package/src/watch/pin.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Listen mode — pinning the answer to the chat that asked for it.
|
|
3
|
+
*
|
|
4
|
+
* A wake puts somebody else's words into pi's prompt, and pi holds tools that
|
|
5
|
+
* write in the user's name. The prompt asks it to answer only the chat it was
|
|
6
|
+
* woken for, but an instruction inside a message is exactly the thing that can
|
|
7
|
+
* talk a model out of following an instruction in a prompt. So the rule is
|
|
8
|
+
* enforced here instead: while an answer is pending, sending anywhere other
|
|
9
|
+
* than the chat that woke pi is refused by the safety interceptor.
|
|
10
|
+
*
|
|
11
|
+
* This is deliberately narrow. It gates outgoing messages only — the tools that
|
|
12
|
+
* speak to other people — and not reading, presence or the calendar, because a
|
|
13
|
+
* pin that blocks unrelated work would be a bug the user meets far more often
|
|
14
|
+
* than an attack.
|
|
15
|
+
*
|
|
16
|
+
* Module state rather than a parameter: the interceptor sees a tool call, not a
|
|
17
|
+
* turn, and one session runs one watcher.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How long a pin can survive without the turn ending.
|
|
22
|
+
*
|
|
23
|
+
* The pin is normally cleared when the agent settles. The timeout is the
|
|
24
|
+
* backstop for the case where that never arrives — a crash, a cancelled run —
|
|
25
|
+
* so a stuck pin cannot lock the user out of their own tools.
|
|
26
|
+
*/
|
|
27
|
+
export const PIN_TTL_MS = 15 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
interface Pin {
|
|
30
|
+
chatId: string;
|
|
31
|
+
/** Chat label, for the refusal message */
|
|
32
|
+
label: string;
|
|
33
|
+
at: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let pin: Pin | undefined;
|
|
37
|
+
|
|
38
|
+
/** Pin the answer of the turn that is about to start to one chat. */
|
|
39
|
+
export function pinWakeTarget(chatId: string, label: string, now = Date.now()): void {
|
|
40
|
+
pin = { chatId, label, at: now };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Release the pin. Safe to call when there is none. */
|
|
44
|
+
export function clearWakeTarget(): void {
|
|
45
|
+
pin = undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The chat an answer is currently owed to, if any. */
|
|
49
|
+
export function getWakeTarget(now = Date.now()): { chatId: string; label: string } | undefined {
|
|
50
|
+
if (!pin) return undefined;
|
|
51
|
+
if (now - pin.at > PIN_TTL_MS) {
|
|
52
|
+
pin = undefined;
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return { chatId: pin.chatId, label: pin.label };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Tools that speak to people, and must therefore stay inside the pinned chat.
|
|
60
|
+
*
|
|
61
|
+
* Reading, presence and calendar tools are absent on purpose: they do not put
|
|
62
|
+
* words in anyone's inbox, and gating them would break ordinary work for no
|
|
63
|
+
* security gain.
|
|
64
|
+
*/
|
|
65
|
+
const OUTGOING_TOOLS = new Set<string>([
|
|
66
|
+
"teams_send_chat_message",
|
|
67
|
+
"teams_send_channel_message",
|
|
68
|
+
"teams_reply_channel_message",
|
|
69
|
+
"teams_create_chat",
|
|
70
|
+
"teams_chat_members",
|
|
71
|
+
"teams_update_message",
|
|
72
|
+
"teams_react",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Why this call must not go through while an answer is pinned, or undefined.
|
|
77
|
+
*
|
|
78
|
+
* The chat is matched on its ID, because that is what the wake prompt hands the
|
|
79
|
+
* model and the only identifier that cannot be talked into meaning a different
|
|
80
|
+
* conversation.
|
|
81
|
+
*/
|
|
82
|
+
export function pinViolation(
|
|
83
|
+
toolName: string,
|
|
84
|
+
params: Record<string, unknown>,
|
|
85
|
+
now = Date.now(),
|
|
86
|
+
): string | undefined {
|
|
87
|
+
const target = getWakeTarget(now);
|
|
88
|
+
if (!target) return undefined;
|
|
89
|
+
if (!OUTGOING_TOOLS.has(toolName)) return undefined;
|
|
90
|
+
|
|
91
|
+
const explain = (what: string) =>
|
|
92
|
+
`Listen mode is answering "${target.label}". ${what} Only that chat may be written to until the ` +
|
|
93
|
+
`answer is done — a message can ask for anything, and this is the part that does not take its word for it.`;
|
|
94
|
+
|
|
95
|
+
if (toolName === "teams_send_channel_message" || toolName === "teams_reply_channel_message") {
|
|
96
|
+
return explain("This would post in a channel.");
|
|
97
|
+
}
|
|
98
|
+
if (toolName === "teams_create_chat") {
|
|
99
|
+
return explain("This would start a different conversation.");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const chat = typeof params.chat === "string" ? params.chat : undefined;
|
|
103
|
+
if (!chat) return explain("This does not name the chat it would write to.");
|
|
104
|
+
if (chat !== target.chatId) {
|
|
105
|
+
return explain(`This would write to "${chat}" instead.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|