@patimweb/pi-teams 1.5.1 → 1.6.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/docs/entra-app-registration.md +46 -4
- package/package.json +1 -1
- package/skills/teams-collaboration/SKILL.md +70 -15
- package/src/auth/msal.ts +127 -20
- package/src/tools/teams-send-channel-message.ts +4 -5
- package/src/tools/teams-send-chat-message.ts +3 -5
- package/src/tools/teams-update-message.ts +3 -1
- package/src/utils/chat-style.ts +65 -0
- package/src/utils/environment.ts +12 -1
- package/src/watch/prompt.ts +2 -3
|
@@ -81,8 +81,23 @@ AADSTS7000218: The request body must contain the following parameter:
|
|
|
81
81
|
|
|
82
82
|
## 3. API permissions
|
|
83
83
|
|
|
84
|
-
**
|
|
85
|
-
**
|
|
84
|
+
**Entra admin center** → **Entra ID** → **App registrations** → your app →
|
|
85
|
+
**API permissions**. Four clicks, and the third one is the one people get
|
|
86
|
+
wrong:
|
|
87
|
+
|
|
88
|
+
1. **Add a permission** — the button above the permission list.
|
|
89
|
+
2. **Microsoft Graph** — the first tile under *Commonly used Microsoft APIs*.
|
|
90
|
+
Not *Azure DevOps*, not *Azure Storage*: those are different APIs and their
|
|
91
|
+
permissions will not help.
|
|
92
|
+
3. **Delegated permissions**, *not* **Application permissions**. pi acts as you.
|
|
93
|
+
An application permission would give the app its own identity and a
|
|
94
|
+
completely different consent model — and the two tiles sit directly under
|
|
95
|
+
each other, which is why this is the usual mistake.
|
|
96
|
+
4. Type each name into **Start typing a permission to filter these results**,
|
|
97
|
+
tick the box, and press **Add permissions** once at the end. Nothing is
|
|
98
|
+
saved until that button is pressed, so a half-filled list can be closed.
|
|
99
|
+
|
|
100
|
+
What you should end up ticking:
|
|
86
101
|
|
|
87
102
|
| Permission | Needed for |
|
|
88
103
|
|---|---|
|
|
@@ -99,7 +114,25 @@ AADSTS7000218: The request body must contain the following parameter:
|
|
|
99
114
|
| `Files.Read.All`, `Sites.Read.All` | channel files |
|
|
100
115
|
|
|
101
116
|
`openid`, `profile` and `offline_access` are deliberately absent: MSAL always
|
|
102
|
-
requests them and rejects them in an explicit scope list.
|
|
117
|
+
requests them and rejects them in an explicit scope list. (You will still see
|
|
118
|
+
them in the picker, under **OpenId permissions**. Leave them alone.)
|
|
119
|
+
|
|
120
|
+
### The Admin consent required column
|
|
121
|
+
|
|
122
|
+
After adding them, the list has a column that decides whether step 4 is a
|
|
123
|
+
five-second job or a ticket to your administrator:
|
|
124
|
+
|
|
125
|
+
| Permission | Admin consent required |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `ChannelMessage.Read.All` | **Yes** — reading channels and threads |
|
|
128
|
+
| `ChannelMember.Read.All` | **Yes** — channel member lists |
|
|
129
|
+
| `ChatMember.ReadWrite` | **Yes** — only if you added it, see below |
|
|
130
|
+
| everything else in the list above | No |
|
|
131
|
+
|
|
132
|
+
The column shows the *default* for an organization, and a tenant can relax it
|
|
133
|
+
per permission, per user or per app — read it as "expect to need an admin", not
|
|
134
|
+
as a property of the permission itself. The scopes showing **Yes** are the ones
|
|
135
|
+
step 4 is about.
|
|
103
136
|
|
|
104
137
|
**What is not needed:** no client secret, no certificate, no redirect URI beyond
|
|
105
138
|
step 2, no application (as opposed to delegated) permissions.
|
|
@@ -112,7 +145,7 @@ again:
|
|
|
112
145
|
|
|
113
146
|
| Permission | Unlocks | Consent |
|
|
114
147
|
|---|---|---|
|
|
115
|
-
| `ChatMember.ReadWrite` | removing someone from a chat (`teams_chat_members`) |
|
|
148
|
+
| `ChatMember.ReadWrite` | removing someone from a chat (`teams_chat_members`) | **admin** — the portal shows it as *Admin consent required: Yes*, and the tool says which scope is missing until it is granted |
|
|
116
149
|
|
|
117
150
|
Without it the tool answers with the exact scope it is missing instead of
|
|
118
151
|
failing at Graph with a consent error. Editing and deleting chat messages,
|
|
@@ -183,6 +216,12 @@ Or edit `~/.pi/agent/pi-teams.json` directly — the same file, mode `0600`:
|
|
|
183
216
|
with your own credentials and MFA, and the page closes itself. Nothing to
|
|
184
217
|
copy or type. Over SSH or in a container, pi falls back to the device code
|
|
185
218
|
flow and shows a short code to enter on any device.
|
|
219
|
+
|
|
220
|
+
In **WSL** the URL is handed to Windows PowerShell, so the browser on the
|
|
221
|
+
Windows side opens — no X server and no `DISPLAY` needed. A launcher that
|
|
222
|
+
cannot be started is reported as an error; if you are upgrading from a
|
|
223
|
+
version that hung here instead, see the last row of the troubleshooting
|
|
224
|
+
table.
|
|
186
225
|
2. `teams_status` — who pi acts as, which token is cached, what it may do.
|
|
187
226
|
3. `teams_doctor` — checks configuration, sign-in, granted scopes and
|
|
188
227
|
connectivity in one go. It reports a missing consent as a **named
|
|
@@ -223,9 +262,12 @@ that tenant on its own.
|
|
|
223
262
|
| `AADSTS50011` — *redirect URI mismatch* | an exact URI is registered that does not match the chosen port | register the port-less `http://localhost`, or pin the port in the same file |
|
|
224
263
|
| `AADSTS7000218` — *client_assertion or client_secret* | **Allow public client flows** is off | step 2b |
|
|
225
264
|
| `AADSTS65001` — *consent required* | permissions added but never consented | sign in again; for the two admin scopes, step 4 |
|
|
265
|
+
| `AADSTS65004` — *request pending* | an **admin consent request** is waiting for approval, or consent was declined once | step 4: an administrator presses **Grant admin consent**. A pending request is under **Enterprise applications** → your app → **Permissions**. Until it is approved the browser comes back with this code instead of a token |
|
|
266
|
+
| `AADSTS53003` — *blocked by Conditional Access* | a policy requires a managed or compliant device, which the **device code** flow cannot satisfy | this is why the browser sign-in has to work rather than quietly fall back: on such a tenant the device code route is a dead end, not a fallback. Use a machine that satisfies the policy, or add pi's loopback redirect to it |
|
|
226
267
|
| `AADSTS50020` — *not a member of the tenant* | wrong tenant, no guest account, or the app is single-tenant | steps 1 and 7 |
|
|
227
268
|
| `AADSTS700016` — *application not found in directory* | wrong tenant ID or client ID | recheck `<DIRECTORY_TENANT_ID>` and `<APPLICATION_CLIENT_ID>` |
|
|
228
269
|
| signed out after about an hour | the tenant grants no `offline_access` | the app needs a refresh token to renew silently; without it, run `teams_login` when the session expires. `teams_doctor` names the missing scope |
|
|
270
|
+
| browser never opens from WSL | older versions called `xdg-open`, which a plain WSL distro does not have — and the failure was swallowed | update pi-teams: current versions hand the URL to Windows PowerShell. If `/mnt/c` is not mounted, set `PI_TEAMS_NO_BROWSER=1` and use the device code flow — see `AADSTS53003` if Conditional Access blocks that |
|
|
229
271
|
|
|
230
272
|
Run `teams_doctor` first for anything in this table: it validates the
|
|
231
273
|
configuration, the cached session, the granted scopes and the connection to
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patimweb/pi-teams",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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",
|
|
@@ -131,36 +131,91 @@ instead of trying anyway.
|
|
|
131
131
|
## How to format a Teams message
|
|
132
132
|
|
|
133
133
|
A Teams chat is a chat bubble, not a document. What arrives badly is long
|
|
134
|
-
prose: the reader has to hunt for
|
|
135
|
-
it.
|
|
134
|
+
prose: the answer is buried, the reader has to hunt for it, and it reads as if a
|
|
135
|
+
machine wrote it. "Keep it short" does not fix that on its own — a shape does.
|
|
136
|
+
The body is rendered as HTML, and markdown is converted for you, so write
|
|
136
137
|
markdown and let the tool do the rest.
|
|
137
138
|
|
|
138
|
-
**
|
|
139
|
+
**The shape, which is nearly always this:**
|
|
139
140
|
|
|
140
141
|
```
|
|
141
|
-
|
|
142
|
+
QA läuft wieder.
|
|
142
143
|
|
|
143
144
|
- Ursache: abgelaufenes Zertifikat
|
|
144
145
|
- Fix: erneuert und neu deployt
|
|
145
146
|
- Offen: Monitoring für 90 Tage
|
|
146
147
|
```
|
|
147
148
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
Line one is the answer. Then a blank line, then only what the reader needs in
|
|
150
|
+
order to act on it. Five lines is already a long chat message.
|
|
151
|
+
|
|
152
|
+
**Before and after.** Both versions say the same thing; only one is a chat:
|
|
153
|
+
|
|
154
|
+
> **So nicht:** Ich habe mir die QA-Umgebung jetzt angeschaut und konnte den
|
|
155
|
+
> Fehler reproduzieren. Das Problem liegt darin, dass das TLS-Zertifikat
|
|
156
|
+
> abgelaufen war, wodurch alle Requests fehlgeschlagen sind. Ich habe es dann
|
|
157
|
+
> erneuert und die Umgebung neu deployt, jetzt läuft wieder alles. Ich würde
|
|
158
|
+
> allerdings noch empfehlen, das Monitoring einzurichten, damit das nicht
|
|
159
|
+
> wieder passiert.
|
|
160
|
+
|
|
161
|
+
> **So:** QA läuft wieder.
|
|
162
|
+
>
|
|
163
|
+
> - Ursache: abgelaufenes TLS-Zertifikat
|
|
164
|
+
> - Fix: erneuert und neu deployt
|
|
165
|
+
> - Empfehlung: Monitoring auf die Ablaufdaten
|
|
166
|
+
|
|
167
|
+
**Three skeletons that cover most messages:**
|
|
168
|
+
|
|
169
|
+
*Antwort auf eine Frage* — the answer, then at most one line of reasoning:
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
Ja, Freitag passt.
|
|
173
|
+
|
|
174
|
+
Ich bin bis 14:00 im Workshop, danach jederzeit.
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
*Status* — outcome first, then what is still open:
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
Deployment ist durch, Release Notes stehen im Wiki: <Link>
|
|
181
|
+
|
|
182
|
+
Offen: Smoke-Test auf Produktion, mache ich heute Nachmittag.
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
*Entscheidung nötig* — the decision, the options, the deadline:
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
Ich brauche bis morgen eine Entscheidung zum Sync-Intervall.
|
|
189
|
+
|
|
190
|
+
- 5 Min: schnell, ~3× Last auf dem SAP-System
|
|
191
|
+
- 15 Min: entspannt, dafür Verzögerung im Reporting
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Rules**
|
|
195
|
+
|
|
196
|
+
- **The first line carries the message.** Someone who reads only that line
|
|
197
|
+
should know the outcome.
|
|
152
198
|
- **Bullets for anything enumerable** — causes, options, next steps. Three items
|
|
153
|
-
and up
|
|
199
|
+
and up are a list; two are a sentence.
|
|
200
|
+
- **One idea per line.** A line break is free; a clause chain held together by
|
|
201
|
+
commas is not.
|
|
154
202
|
- **Bold the one thing that must not be missed**, and only that. Bold used
|
|
155
203
|
everywhere is bold nowhere.
|
|
156
204
|
- **Numbers, names and dates exactly as they are.** Do not round a figure or
|
|
157
205
|
paraphrase a deadline into something friendlier.
|
|
158
206
|
- **One question per message.** Two questions get one answer.
|
|
207
|
+
- **No greeting and no closing.** "Hallo zusammen", "Ich habe mir das mal
|
|
208
|
+
angeschaut", "Kurz zusammengefasst:" and "Sag Bescheid, wenn du Fragen hast"
|
|
209
|
+
are all noise in a chat. The message starts with the answer and stops when it
|
|
210
|
+
is done.
|
|
211
|
+
- **Over five lines, it is a document.** Put it in a wiki page or a file and send
|
|
212
|
+
the link with one sentence around it.
|
|
159
213
|
|
|
160
214
|
**What Teams renders:** `**bold**`, `*italic*`, `` `code` ``, `~~struck~~`,
|
|
161
|
-
`[label](url)`, `- bullets`, `1. numbered`,
|
|
162
|
-
a bold line — fine for structure, not for hierarchy. **Tables are not
|
|
163
|
-
in a chat**: convert them to bullets, or put the table in a file and
|
|
215
|
+
`[label](url)`, `- bullets`, `1. numbered`, and code fences. A `# heading`
|
|
216
|
+
arrives as a bold line — fine for structure, not for hierarchy. **Tables are not
|
|
217
|
+
supported in a chat**: convert them to bullets, or put the table in a file and
|
|
218
|
+
link it.
|
|
164
219
|
|
|
165
220
|
**Language and tone:** answer in the language of the conversation, in the
|
|
166
221
|
register of the people in it. Short forms are normal in a chat ("passt", "ok,
|
|
@@ -323,9 +378,9 @@ teams_watch:
|
|
|
323
378
|
2. If an answer is warranted, send it with `teams_send_chat_message` to the
|
|
324
379
|
**chat ID from the prompt** — never re-resolve the chat by name. pi posts as
|
|
325
380
|
the user, so the wrong chat is a wrong statement in the user's name.
|
|
326
|
-
3. Keep it to the shape in *How to format a Teams message*: answer
|
|
327
|
-
|
|
328
|
-
readable at a glance.
|
|
381
|
+
3. Keep it to the shape in *How to format a Teams message*: the answer on the
|
|
382
|
+
first line, at most five short lines, bullets for anything enumerable. An
|
|
383
|
+
unrequested message has to be readable at a glance.
|
|
329
384
|
4. If no answer is warranted, say so in one line and stop. Do not react, do not
|
|
330
385
|
touch other chats, do not post to a channel.
|
|
331
386
|
|
package/src/auth/msal.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* whether a browser is actually reachable, and open it.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { spawn } from "node:child_process";
|
|
10
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
11
11
|
import {
|
|
12
12
|
ConfidentialClientApplication,
|
|
13
13
|
LogLevel,
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
type Configuration,
|
|
16
16
|
} from "@azure/msal-node";
|
|
17
17
|
import type { TeamsConnection } from "../config/index.ts";
|
|
18
|
-
import { browserUnavailableReason, canOpenBrowser } from "../utils/environment.ts";
|
|
18
|
+
import { browserUnavailableReason, canOpenBrowser, isWsl } from "../utils/environment.ts";
|
|
19
19
|
import { createCachePlugin } from "./cache-plugin.ts";
|
|
20
20
|
|
|
21
21
|
export { browserUnavailableReason, canOpenBrowser };
|
|
@@ -101,32 +101,139 @@ export function resetApps(): void {
|
|
|
101
101
|
// Browser
|
|
102
102
|
// ---------------------------------------------------------------------------
|
|
103
103
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
// title cmd.exe would otherwise take the URL for.
|
|
113
|
-
["cmd.exe", ["/c", "start", "", url]]
|
|
114
|
-
: ["xdg-open", [url]];
|
|
104
|
+
/**
|
|
105
|
+
* The Windows-side shell a WSL session hands its URL to.
|
|
106
|
+
*
|
|
107
|
+
* Windows PowerShell rather than `cmd.exe`: the authorize URL carries several
|
|
108
|
+
* `&`, and `cmd /c start` reads the first of them as a command separator and
|
|
109
|
+
* opens a truncated URL. Single quotes survive them.
|
|
110
|
+
*/
|
|
111
|
+
const WSL_POWERSHELL = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe";
|
|
115
112
|
|
|
113
|
+
/** How long to wait for a launcher that neither exits nor errors. */
|
|
114
|
+
const LAUNCH_EXIT_GRACE_MS = 2000;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The command that puts `url` in front of the user in a browser.
|
|
118
|
+
*
|
|
119
|
+
* Split out of `openBrowser` so the shapes stay testable: the Windows and WSL
|
|
120
|
+
* forms are easy to get subtly wrong, and all of them fail quietly when they
|
|
121
|
+
* are.
|
|
122
|
+
*/
|
|
123
|
+
export function browserLaunch(
|
|
124
|
+
url: string,
|
|
125
|
+
target: { platform?: NodeJS.Platform; wsl?: boolean } = {},
|
|
126
|
+
): { command: string; args: string[] } {
|
|
127
|
+
const platform = target.platform ?? process.platform;
|
|
128
|
+
|
|
129
|
+
if (target.wsl ?? isWsl()) {
|
|
130
|
+
// Escaping a quote inside a PowerShell single-quoted string means doubling
|
|
131
|
+
// it. Nothing else in the URL needs attention.
|
|
132
|
+
const quoted = url.replace(/'/g, "''");
|
|
133
|
+
return {
|
|
134
|
+
command: WSL_POWERSHELL,
|
|
135
|
+
args: ["-NoProfile", "-NonInteractive", "-Command", `Start-Process '${quoted}'`],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (platform === "darwin") return { command: "open", args: [url] };
|
|
140
|
+
|
|
141
|
+
if (platform === "win32") {
|
|
142
|
+
// `start` is a shell builtin, and the empty string is the window title
|
|
143
|
+
// cmd.exe would otherwise take the URL for.
|
|
144
|
+
return { command: "cmd.exe", args: ["/c", "start", "", url] };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { command: "xdg-open", args: [url] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Run a launcher and wait until it is clear whether it worked.
|
|
152
|
+
*
|
|
153
|
+
* Two things have to be caught, and both were lost when this resolved on spawn.
|
|
154
|
+
* A missing launcher — `xdg-open` on a fresh WSL distro — reports ENOENT on
|
|
155
|
+
* `error` and never exits. A launcher that is present but finds no browser
|
|
156
|
+
* exits non-zero, which is how `xdg-open` says so.
|
|
157
|
+
*
|
|
158
|
+
* Resolving on spawn made both look like success, so the sign-in sat waiting
|
|
159
|
+
* for a redirect from a browser that had never opened, until the five minute
|
|
160
|
+
* timeout replaced the real cause with "Browser sign-in timed out".
|
|
161
|
+
*/
|
|
162
|
+
export function launchBrowser(
|
|
163
|
+
command: string,
|
|
164
|
+
args: string[],
|
|
165
|
+
graceMs = LAUNCH_EXIT_GRACE_MS,
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
let child: ChildProcess;
|
|
116
169
|
try {
|
|
117
|
-
|
|
118
|
-
detached: true,
|
|
119
|
-
stdio: "ignore",
|
|
120
|
-
});
|
|
121
|
-
child.on("error", reject);
|
|
122
|
-
child.unref();
|
|
123
|
-
resolve();
|
|
170
|
+
child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] });
|
|
124
171
|
} catch (err) {
|
|
125
172
|
reject(err instanceof Error ? err : new Error(String(err)));
|
|
173
|
+
return;
|
|
126
174
|
}
|
|
175
|
+
|
|
176
|
+
let settled = false;
|
|
177
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
178
|
+
let stderr = "";
|
|
179
|
+
|
|
180
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
181
|
+
stderr += String(chunk);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const settle = (err?: Error) => {
|
|
185
|
+
if (settled) return;
|
|
186
|
+
settled = true;
|
|
187
|
+
if (timer) clearTimeout(timer);
|
|
188
|
+
child.unref();
|
|
189
|
+
if (err) reject(err);
|
|
190
|
+
else resolve();
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// A launcher that stays alive is waiting on the browser, not failing.
|
|
194
|
+
timer = setTimeout(() => settle(), graceMs);
|
|
195
|
+
|
|
196
|
+
child.once("error", (err: NodeJS.ErrnoException) => {
|
|
197
|
+
const detail = err.code === "ENOENT" ? `${command} was not found` : err.message;
|
|
198
|
+
settle(new Error(`could not open a browser: ${detail}`));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
child.once("close", (code, signal) => {
|
|
202
|
+
if (code === 0) settle();
|
|
203
|
+
else {
|
|
204
|
+
// `close`, not `exit`: the exit event can fire before the stderr pipe has
|
|
205
|
+
// drained, and the reason xdg-open gives is on stderr.
|
|
206
|
+
const why = stderr.trim() ? `: ${stderr.trim()}` : "";
|
|
207
|
+
settle(
|
|
208
|
+
new Error(
|
|
209
|
+
`could not open a browser: ${command} exited with ` +
|
|
210
|
+
`${code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`}${why}`,
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
127
215
|
});
|
|
128
216
|
}
|
|
129
217
|
|
|
218
|
+
/** Open a URL in the user's default browser, detached from this process. */
|
|
219
|
+
export async function openBrowser(url: string): Promise<void> {
|
|
220
|
+
const { command, args } = browserLaunch(url);
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
await launchBrowser(command, args);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
226
|
+
if (isWsl()) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`${cause}. From WSL the URL is handed to Windows PowerShell at ` +
|
|
229
|
+
`${WSL_POWERSHELL}; if the Windows drive is not mounted there, set ` +
|
|
230
|
+
"PI_TEAMS_NO_BROWSER=1 to use the device code flow instead.",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
throw err instanceof Error ? err : new Error(cause);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
130
237
|
// ---------------------------------------------------------------------------
|
|
131
238
|
// Browser response pages
|
|
132
239
|
// ---------------------------------------------------------------------------
|
|
@@ -10,6 +10,7 @@ import { Type } from "typebox";
|
|
|
10
10
|
import { replyToChannelMessage, sendChannelMessage } from "../graph/messages.ts";
|
|
11
11
|
import { resolveUserId } from "../graph/me.ts";
|
|
12
12
|
import { auditWrite } from "../safety/audit.ts";
|
|
13
|
+
import { CHAT_MESSAGE_BODY_DESCRIPTION, CHAT_SHAPE_GUIDELINE } from "../utils/chat-style.ts";
|
|
13
14
|
import { applyAiFooter } from "../utils/disclosure.ts";
|
|
14
15
|
import { assertAccess } from "../safety/index.ts";
|
|
15
16
|
import { truncate } from "../utils/formatting.ts";
|
|
@@ -58,10 +59,7 @@ async function resolveMentions(
|
|
|
58
59
|
const sharedParams = {
|
|
59
60
|
channel: Type.String({ description: "Channel name or ID, or a 'Team/Channel' path" }),
|
|
60
61
|
team: Type.Optional(Type.String({ description: "Team name or ID (omit when using a path)" })),
|
|
61
|
-
body: Type.String({
|
|
62
|
-
description:
|
|
63
|
-
"Message text as lightweight markdown (bold, italics, code, bullets, numbered lists, links)",
|
|
64
|
-
}),
|
|
62
|
+
body: Type.String({ description: CHAT_MESSAGE_BODY_DESCRIPTION }),
|
|
65
63
|
account: AccountParam,
|
|
66
64
|
tenant: TenantParam,
|
|
67
65
|
mentions: Type.Optional(
|
|
@@ -85,7 +83,7 @@ export const teamsSendChannelMessageTool = {
|
|
|
85
83
|
promptGuidelines: [
|
|
86
84
|
"Write in the user's voice — the post is attributed to them, not to an assistant.",
|
|
87
85
|
"Show the exact text and the target channel before posting when there is any ambiguity.",
|
|
88
|
-
|
|
86
|
+
CHAT_SHAPE_GUIDELINE,
|
|
89
87
|
"Answer in the language of the conversation you are writing into, and match its register.",
|
|
90
88
|
],
|
|
91
89
|
|
|
@@ -180,6 +178,7 @@ export const teamsReplyChannelMessageTool = {
|
|
|
180
178
|
messageId: Type.String({ description: "ID of the thread's opening message" }),
|
|
181
179
|
}),
|
|
182
180
|
promptSnippet: "Reply in a Teams channel thread as the user",
|
|
181
|
+
promptGuidelines: [CHAT_SHAPE_GUIDELINE],
|
|
183
182
|
|
|
184
183
|
async execute(
|
|
185
184
|
_toolCallId: string,
|
|
@@ -21,6 +21,7 @@ import { formatGraphError } from "../utils/errors.ts";
|
|
|
21
21
|
import { resolveUserId } from "../graph/me.ts";
|
|
22
22
|
import { auditWrite } from "../safety/audit.ts";
|
|
23
23
|
import { assertAccess } from "../safety/index.ts";
|
|
24
|
+
import { CHAT_MESSAGE_BODY_DESCRIPTION, CHAT_SHAPE_GUIDELINE } from "../utils/chat-style.ts";
|
|
24
25
|
import { applyAiFooter } from "../utils/disclosure.ts";
|
|
25
26
|
import { truncate } from "../utils/formatting.ts";
|
|
26
27
|
import { requireChat } from "./resolve.ts";
|
|
@@ -56,10 +57,7 @@ export const teamsSendChatMessageTool = {
|
|
|
56
57
|
"where the mention should appear.",
|
|
57
58
|
parameters: Type.Object({
|
|
58
59
|
chat: Type.String({ description: "Chat ID, group chat topic, or a participant's name/e-mail" }),
|
|
59
|
-
body: Type.String({
|
|
60
|
-
description:
|
|
61
|
-
"Message text as lightweight markdown (bold, italics, code, bullets, numbered lists, links)",
|
|
62
|
-
}),
|
|
60
|
+
body: Type.String({ description: CHAT_MESSAGE_BODY_DESCRIPTION }),
|
|
63
61
|
account: AccountParam,
|
|
64
62
|
tenant: TenantParam,
|
|
65
63
|
mentions: Type.Optional(
|
|
@@ -79,7 +77,7 @@ export const teamsSendChatMessageTool = {
|
|
|
79
77
|
"Write the message in the user's voice — it is sent from their account, not from an assistant.",
|
|
80
78
|
"Show the user the exact text before sending when the intent is even slightly ambiguous.",
|
|
81
79
|
"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.",
|
|
82
|
-
|
|
80
|
+
CHAT_SHAPE_GUIDELINE,
|
|
83
81
|
"Answer in the language of the conversation you are writing into, and match its register.",
|
|
84
82
|
],
|
|
85
83
|
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { Type } from "typebox";
|
|
15
15
|
import { updateMessage } from "../graph/messages.ts";
|
|
16
16
|
import { auditWrite } from "../safety/audit.ts";
|
|
17
|
+
import { CHAT_MESSAGE_BODY_DESCRIPTION, CHAT_SHAPE_GUIDELINE } from "../utils/chat-style.ts";
|
|
17
18
|
import { applyAiFooter } from "../utils/disclosure.ts";
|
|
18
19
|
import { requireChat } from "./resolve.ts";
|
|
19
20
|
import {
|
|
@@ -36,7 +37,7 @@ export const teamsUpdateMessageTool = {
|
|
|
36
37
|
"Use this instead of deleting a message and sending a corrected copy.",
|
|
37
38
|
parameters: Type.Object({
|
|
38
39
|
messageId: Type.String({ description: "ID of the message to edit" }),
|
|
39
|
-
body: Type.String({ description:
|
|
40
|
+
body: Type.String({ description: CHAT_MESSAGE_BODY_DESCRIPTION }),
|
|
40
41
|
chat: Type.String({ description: "Chat ID, group chat topic, or a participant's name/e-mail" }),
|
|
41
42
|
html: Type.Optional(Type.Boolean({ description: "Treat 'body' as raw HTML instead of markdown" })),
|
|
42
43
|
account: AccountParam,
|
|
@@ -45,6 +46,7 @@ export const teamsUpdateMessageTool = {
|
|
|
45
46
|
promptSnippet: "Edit a message the user sent in Teams",
|
|
46
47
|
promptGuidelines: [
|
|
47
48
|
"Use teams_update_message to correct a message the user already sent, rather than deleting it and posting a replacement.",
|
|
49
|
+
CHAT_SHAPE_GUIDELINE,
|
|
48
50
|
],
|
|
49
51
|
|
|
50
52
|
async execute(
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of a Teams message.
|
|
3
|
+
*
|
|
4
|
+
* A chat bubble is read at a glance, on a phone, between two other things. The
|
|
5
|
+
* failure mode is well known and it is not a lack of language skill: the model
|
|
6
|
+
* writes a *document*. It opens with context, builds up to the point, and
|
|
7
|
+
* closes with a summary — so the reader has to hunt for the answer, which is
|
|
8
|
+
* exactly the "wall of text" people complain about.
|
|
9
|
+
*
|
|
10
|
+
* Telling the model to "be brief" does not fix it. Vague length advice is the
|
|
11
|
+
* weakest kind of formatting instruction; an explicit shape and a worked
|
|
12
|
+
* example are the strongest, and they have to sit where the message is being
|
|
13
|
+
* composed rather than in a document the model may or may not have loaded.
|
|
14
|
+
*
|
|
15
|
+
* So one shape, stated the same way in every place that matters:
|
|
16
|
+
*
|
|
17
|
+
* - the `body` parameter description — in context at the moment of writing,
|
|
18
|
+
* which is the highest-attention placement available,
|
|
19
|
+
* - the per-tool guideline bullet — in context for the whole session, and
|
|
20
|
+
* flat in the Guidelines list, so it has to name its tools,
|
|
21
|
+
* - the skill and the listen-mode prompt — which carry the worked example
|
|
22
|
+
* (`skills/teams-collaboration/SKILL.md`).
|
|
23
|
+
*
|
|
24
|
+
* Keeping it in one module is the point. Four tools used to carry their own
|
|
25
|
+
* copy of one sentence, they had drifted, and the sentence they shared was
|
|
26
|
+
* "three short paragraphs at most" — which permits prose, and got prose.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a message body is, and what shape it has.
|
|
31
|
+
*
|
|
32
|
+
* Used as the `body` parameter description on every tool that sends one, so it
|
|
33
|
+
* is read at the moment the text is written. Kept short on purpose: it is in
|
|
34
|
+
* the tool schema, and every tool in this package is in context at once.
|
|
35
|
+
*/
|
|
36
|
+
export const CHAT_MESSAGE_BODY_DESCRIPTION =
|
|
37
|
+
"Message text as lightweight markdown, in chat shape: the answer on the first line, then at most 5 short " +
|
|
38
|
+
"lines, `- ` bullets for anything enumerable, one idea per line. No greeting, no closing, no summary. " +
|
|
39
|
+
"Rendered: **bold**, *italic*, `code`, ~~struck~~, [label](url), `- ` bullets, `1. ` numbered, ``` fences. " +
|
|
40
|
+
"A `#` heading arrives as a bold line; tables are not rendered at all.";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The same shape as one guideline bullet.
|
|
44
|
+
*
|
|
45
|
+
* `promptGuidelines` are appended flat to the system prompt's Guidelines
|
|
46
|
+
* section with no tool-name prefix, so this names every tool it applies to —
|
|
47
|
+
* otherwise the model cannot tell which "the message body" means. It stays on
|
|
48
|
+
* one line for the same reason: it is a bullet in someone else's list.
|
|
49
|
+
*/
|
|
50
|
+
export const CHAT_SHAPE_GUIDELINE =
|
|
51
|
+
"teams_send_chat_message, teams_send_channel_message, teams_reply_channel_message and teams_update_message " +
|
|
52
|
+
"write chat shape, not a document: the answer on the first line, at most 5 short lines, `- ` bullets for " +
|
|
53
|
+
"enumerable items, no greeting, no closing summary, no tables.";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The shape as prose, for the listen-mode prompt.
|
|
57
|
+
*
|
|
58
|
+
* Listen mode is the case where this matters most: an answer that arrives
|
|
59
|
+
* unrequested has to be readable at a glance, because the recipient is not
|
|
60
|
+
* waiting for it and did not ask for it.
|
|
61
|
+
*/
|
|
62
|
+
export const CHAT_SHAPE_FOR_LISTEN_MODE =
|
|
63
|
+
"Format it as a chat bubble, not a document: the answer on the first line, at most 5 short lines, " +
|
|
64
|
+
"`- ` bullets for anything enumerable, no greeting and no closing. The recipient is not expecting this " +
|
|
65
|
+
"message, so it has to be readable at a glance.";
|
package/src/utils/environment.ts
CHANGED
|
@@ -12,10 +12,16 @@
|
|
|
12
12
|
* The interactive sign-in needs two things: a browser to open, and a loopback
|
|
13
13
|
* listener that browser can reach. Over SSH both assumptions break — `open`
|
|
14
14
|
* would launch a browser on the *remote* machine, where nobody is looking — so
|
|
15
|
-
* those sessions get the device code flow instead.
|
|
15
|
+
* those sessions get the device code flow instead. In WSL the browser is on
|
|
16
|
+
* the Windows side of the same machine, which is what `isWsl` accounts for.
|
|
16
17
|
*
|
|
17
18
|
* Set `PI_TEAMS_NO_BROWSER=1` to force that fallback anywhere.
|
|
18
19
|
*/
|
|
20
|
+
export function isWsl(): boolean {
|
|
21
|
+
if (process.platform !== "linux") return false;
|
|
22
|
+
return !!(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
export function canOpenBrowser(): boolean {
|
|
20
26
|
if (process.env.PI_TEAMS_NO_BROWSER) return false;
|
|
21
27
|
|
|
@@ -24,6 +30,11 @@ export function canOpenBrowser(): boolean {
|
|
|
24
30
|
|
|
25
31
|
if (process.platform === "darwin" || process.platform === "win32") return true;
|
|
26
32
|
|
|
33
|
+
// WSL is not a headless Linux: the browser lives on Windows and `openBrowser`
|
|
34
|
+
// hands the URL over to it. DISPLAY says nothing either way — WSLg sets it on
|
|
35
|
+
// Windows 11 and not on Windows 10, while the handoff works on both.
|
|
36
|
+
if (isWsl()) return true;
|
|
37
|
+
|
|
27
38
|
// Linux and the BSDs need a display server for a browser to appear at all.
|
|
28
39
|
return !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
29
40
|
}
|
package/src/watch/prompt.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { MessageSummary, SignedInUser } from "../types.ts";
|
|
19
|
+
import { CHAT_SHAPE_FOR_LISTEN_MODE } from "../utils/chat-style.ts";
|
|
19
20
|
import type { WatchEvent } from "./loop.ts";
|
|
20
21
|
|
|
21
22
|
function senderName(message: MessageSummary): string {
|
|
@@ -114,9 +115,7 @@ export function composeWatchPrompt(event: WatchEvent, me?: SignedInUser): string
|
|
|
114
115
|
"the sending tool appends the configured AI disclosure itself.",
|
|
115
116
|
"- If it does not: say so in one line and stop.",
|
|
116
117
|
"",
|
|
117
|
-
|
|
118
|
-
"bullets for anything enumerable, no tables. The recipient is not expecting this message, so it has " +
|
|
119
|
-
"to be readable at a glance.",
|
|
118
|
+
CHAT_SHAPE_FOR_LISTEN_MODE,
|
|
120
119
|
"",
|
|
121
120
|
"Do not react, do not touch other chats, and do not send anything to a channel.",
|
|
122
121
|
].join("\n");
|