@patimweb/pi-teams 1.8.0 → 1.9.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 +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 +36 -4
- package/src/watch/pin.ts +109 -0
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
|
+
}
|