@patimweb/pi-teams 1.5.0 → 1.6.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 CHANGED
@@ -87,7 +87,10 @@ What it does, and what it deliberately does not:
87
87
  message is still unread for you in Teams. So switching listen mode back on
88
88
  answers what you missed while pi was not running — and a chat you already read
89
89
  in Teams stays quiet. A backlog is drained a few chats per tick and capped by
90
- `maxTriggersPerHour`, so a long absence does not produce a burst.
90
+ `maxTriggersPerHour`, so a long absence does not produce a burst. A chat held
91
+ back by its `cooldownSeconds`, or by that hourly cap, is *delayed, never
92
+ dropped*: it keeps its place in line and is answered as soon as the wait is
93
+ over. `teams_watch action: status` shows how many are waiting right now.
91
94
  - **Never wakes for its own messages.** pi posts as you, so its own reply comes
92
95
  back as "my own message" and stops the loop.
93
96
  - **Respects the read rules.** A chat excluded by `permissions.read.chats` is
@@ -652,7 +655,8 @@ network connection.
652
655
  | "Blocked by configuration" | Your own scope rules. `teams_permissions action: test` shows which rule |
653
656
  | "cannot run on an app-only token" | Set `authMode: "interactive"` and run `teams_login` |
654
657
  | 403 reading channel messages | `ChannelMessage.Read.All` needs **admin** consent — see "Who has to approve what" |
655
- | Listen mode does nothing | `teams_watch action: status` says whether it is enabled and running. Only messages arriving *after* it was switched on wake pi |
658
+ | Listen mode does nothing | `teams_watch action: status` says whether it is enabled and running, how many chats it tracks, and where the cursor lives. A chat you have already read in Teams stays quiet by design, and one held back by its cooldown or the hourly cap shows up as *waiting* |
659
+ | Listen mode never answers one specific chat | `waiting` in `teams_watch action: status` says whether it is sitting out a `cooldownSeconds` window; chats it has already decided about are in the cursor under `~/.pi/agent/pi-teams-watch/` |
656
660
  | Listen mode answers too much | Narrow `watch.chats` and `watch.from`, raise `cooldownSeconds`, lower `maxTriggersPerHour` |
657
661
  | Listen mode stopped by itself | `teams_watch action: status` shows the last polling error; a revoked session or a sleeping laptop is the usual cause, and it resumes on its own |
658
662
  | Throttled | Graph rate limit; the client retries with back-off, then reports it |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-teams",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
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 the point, and it reads as if a machine wrote
135
- it. The body is rendered as HTML — markdown is converted for you, so write
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
- **Reach for this shape, not for paragraphs:**
139
+ **The shape, which is nearly always this:**
139
140
 
140
141
  ```
141
- Kurz: die QA-Umgebung läuft wieder.
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
- - **Lead with the answer.** The first line should work as the whole message for
149
- someone who reads nothing else.
150
- - **Three short paragraphs at most.** If it needs more, it is a document — put
151
- it in a file or a wiki page and link it.
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 belong in a list, not in a sentence with commas.
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`, ``` fences. A `# heading` arrives as
162
- a bold line — fine for structure, not for hierarchy. **Tables are not supported
163
- in a chat**: convert them to bullets, or put the table in a file and link it.
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 first, three
327
- short paragraphs at most, bullets for lists. An unrequested message has to be
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/status.ts CHANGED
@@ -81,7 +81,12 @@ export function buildConnectionLabel(card: ConnectionCard | undefined): string {
81
81
  if (!card.signedIn) return `Teams · ${card.account} · not signed in`;
82
82
 
83
83
  const scope = card.tenant === card.account ? card.account : `${card.account}/${card.tenant}`;
84
- const listening = card.watching ? " · 👂 listening" : "";
84
+ // A monochrome mark, not an emoji: the rest of the label is drawn in the
85
+ // terminal's own font (the ✓ included), and a colour glyph next to it looks
86
+ // like a different interface. ◉ reads as "live", which is what the label is
87
+ // saying — and it survives a terminal without emoji fonts, where 👂 would
88
+ // come out as tofu.
89
+ const listening = card.watching ? " · ◉ listening" : "";
85
90
  return `✓ Teams · ${scope}${card.user ? ` · ${card.user}` : ""}${listening}`;
86
91
  }
87
92
 
@@ -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
- "Format for a chat, not for a document: lead with the answer, three short paragraphs at most, bullets for lists. Bold, italics, code, links and bullets are rendered; headings become bold lines and tables are not supported — keep those out.",
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,
@@ -5,13 +5,23 @@
5
5
  * from the user typing it. Hence the order of operations — resolve the chat,
6
6
  * check the chat rules, check every participant against the people rules,
7
7
  * resolve the mentions, send, then record the audit entry.
8
+ *
9
+ * The last step is the read cursor. Sending from Graph does not move it — only
10
+ * the user opening the chat, or teams_mark_read, does — so without this a chat
11
+ * pi has just answered keeps its unread marker in Teams, which is the one thing
12
+ * that looks like nobody dealt with it. Replying is reading, so the message
13
+ * would arrive with the chat still bold; the mark is best effort and comes last,
14
+ * because a send that went out must not be reported as failed.
8
15
  */
9
16
 
10
17
  import { Type } from "typebox";
18
+ import { setChatReadState } from "../graph/chats.ts";
11
19
  import { sendChatMessage } from "../graph/messages.ts";
20
+ import { formatGraphError } from "../utils/errors.ts";
12
21
  import { resolveUserId } from "../graph/me.ts";
13
22
  import { auditWrite } from "../safety/audit.ts";
14
23
  import { assertAccess } from "../safety/index.ts";
24
+ import { CHAT_MESSAGE_BODY_DESCRIPTION, CHAT_SHAPE_GUIDELINE } from "../utils/chat-style.ts";
15
25
  import { applyAiFooter } from "../utils/disclosure.ts";
16
26
  import { truncate } from "../utils/formatting.ts";
17
27
  import { requireChat } from "./resolve.ts";
@@ -47,10 +57,7 @@ export const teamsSendChatMessageTool = {
47
57
  "where the mention should appear.",
48
58
  parameters: Type.Object({
49
59
  chat: Type.String({ description: "Chat ID, group chat topic, or a participant's name/e-mail" }),
50
- body: Type.String({
51
- description:
52
- "Message text as lightweight markdown (bold, italics, code, bullets, numbered lists, links)",
53
- }),
60
+ body: Type.String({ description: CHAT_MESSAGE_BODY_DESCRIPTION }),
54
61
  account: AccountParam,
55
62
  tenant: TenantParam,
56
63
  mentions: Type.Optional(
@@ -70,7 +77,7 @@ export const teamsSendChatMessageTool = {
70
77
  "Write the message in the user's voice — it is sent from their account, not from an assistant.",
71
78
  "Show the user the exact text before sending when the intent is even slightly ambiguous.",
72
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.",
73
- "Format for a chat, not for a document: lead with the answer, three short paragraphs at most, bullets for lists. Bold, italics, code, links and bullets are rendered; headings become bold lines and tables are not supported — keep those out.",
80
+ CHAT_SHAPE_GUIDELINE,
74
81
  "Answer in the language of the conversation you are writing into, and match its register.",
75
82
  ],
76
83
 
@@ -135,6 +142,18 @@ export const teamsSendChatMessageTool = {
135
142
  summary: truncate(body, 200),
136
143
  });
137
144
 
145
+ // Replying is reading: clear the unread marker the answer was written for.
146
+ // Deliberately after the audit entry, so the message that went out is
147
+ // recorded even when the read-mark cannot be moved.
148
+ let readNote = "";
149
+ if (me) {
150
+ try {
151
+ await setChatReadState(conn, chat.id, me, true, { signal });
152
+ } catch (err) {
153
+ readNote = `\n\n⚠️ The chat could not be marked as read: ${formatGraphError(err)}`;
154
+ }
155
+ }
156
+
138
157
  return textResult(
139
158
  [
140
159
  `✅ Message sent to **${chat.label}** as ${me?.displayName ?? "you"} (${connectionLabel(conn)}).`,
@@ -142,7 +161,7 @@ export const teamsSendChatMessageTool = {
142
161
  `> ${truncate(body, 300)}`,
143
162
  "",
144
163
  `messageId: ${sent.id}`,
145
- ].join("\n"),
164
+ ].join("\n") + readNote,
146
165
  { chatId: chat.id, messageId: sent.id },
147
166
  );
148
167
  } catch (err) {
@@ -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: "The new message text (lightweight markdown by default)" }),
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(
@@ -46,7 +46,8 @@ const CURSOR_NOTES = [
46
46
  + "on therefore answers what you missed, and a chat you have already read in Teams stays quiet.",
47
47
  "",
48
48
  "A backlog is drained over several ticks and capped by the hourly wake limit, so a long absence does "
49
- + "not produce a burst of answers.",
49
+ + "not produce a burst of answers. A chat held back by its cooldown or by that limit is delayed, "
50
+ + "never dropped: it stays open and is answered as soon as the wait is over.",
50
51
  ].join("\n");
51
52
 
52
53
  /** Renders the effective settings the same way the config file would. */
@@ -93,6 +94,12 @@ function runtimeLines(runtime: WatchRuntimeStatus | undefined, conn: TeamsConnec
93
94
  `- chats tracked: ${runtime.trackedChats}`,
94
95
  `- woken this hour: ${runtime.wakesThisHour}`,
95
96
  ];
97
+ if (runtime.waiting > 0) {
98
+ lines.push(
99
+ `- waiting: ${runtime.waiting} chat(s) — held back by their cooldown or by the wake limit, "
100
+ + "and answered once that clears`,
101
+ );
102
+ }
96
103
  if (runtime.lastTickAt) {
97
104
  lines.push(`- last poll: ${new Date(runtime.lastTickAt).toLocaleTimeString()}`);
98
105
  }
@@ -115,9 +122,8 @@ export const teamsWatchTool = {
115
122
  name: "teams_watch",
116
123
  description:
117
124
  "Control listen mode: pi polls your Teams chats and turns an incoming message into a prompt it answers. " +
118
- "A chat wakes it when it has moved since pi last looked, so switching listen mode on also answers " +
119
- "what arrived while pi was not running; only the very first run on an account records history " +
120
- "without answering it. " +
125
+ "A chat wakes it when it has moved since pi last looked, so switching listen mode on also answers "
126
+ + "what arrived while pi was not running. " +
121
127
  "Use action 'status' to see the current settings, 'enable' or 'disable' to switch it, and the optional " +
122
128
  "fields to narrow what may wake pi (chat patterns, mentions only, interval, cooldown, hourly limit). " +
123
129
  "The watcher runs in the extension session; this tool changes the configuration it reads.",
@@ -216,7 +222,7 @@ export const teamsWatchTool = {
216
222
  "",
217
223
  ...runtimeLines(runtime, conn),
218
224
  "",
219
- ...CURSOR_NOTES,
225
+ CURSOR_NOTES,
220
226
  ].join("\n"),
221
227
  { watch, runtime },
222
228
  );
@@ -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.";
@@ -15,6 +15,11 @@
15
15
  * the stored one. Written after every tick, so a crash costs nothing but the
16
16
  * tick it died in.
17
17
  *
18
+ * What lands here is a *decision*, not a sighting: a chat whose wake was only
19
+ * postponed — inside its cooldown, or over the hourly limit — is deliberately
20
+ * kept out of the file (see `noteDeferral` in ./index.ts), because an entry here
21
+ * means the watcher will never look at that chat again.
22
+ *
18
23
  * One file per account **and** tenant, next to the token cache: two accounts in
19
24
  * one process must not share a cursor, and neither must a guest tenant beneath
20
25
  * the same account.
@@ -51,6 +51,20 @@ export interface WatchState {
51
51
  lastTriggered: Map<string, number>;
52
52
  /** epoch ms of every wake, pruned to the last hour */
53
53
  triggers: number[];
54
+ /**
55
+ * chatId → epoch ms before which this chat must not be looked at again.
56
+ *
57
+ * The counterpart of `seen`, and the whole point of the pair: a chat whose
58
+ * decision was only *postponed* — inside its cooldown, or held back by the
59
+ * hourly wake limit — is deliberately **not** written to the cursor, because
60
+ * the cursor means "pi has looked at this and will not look again". A message
61
+ * written down there is a message nobody ever answers.
62
+ *
63
+ * In memory on purpose: it only saves the Graph calls of re-examining the chat
64
+ * every tick until the wait is over, so losing it on a restart costs one extra
65
+ * examination — never a message.
66
+ */
67
+ retryAfter: Map<string, number>;
54
68
  }
55
69
 
56
70
  /**
@@ -67,7 +81,42 @@ export const MAX_CHAT_ATTEMPTS = 3;
67
81
  * Omitted means the very first run, which is the only run that skips history.
68
82
  */
69
83
  export function createWatchState(seen?: Iterable<[string, string]>): WatchState {
70
- return { seen: new Map(seen), attempts: new Map(), lastTriggered: new Map(), triggers: [] };
84
+ return {
85
+ seen: new Map(seen),
86
+ attempts: new Map(),
87
+ lastTriggered: new Map(),
88
+ triggers: [],
89
+ retryAfter: new Map(),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Record that a chat has been looked at and decided.
95
+ *
96
+ * "Decided" is the operative word: only a final decision may write the cursor,
97
+ * since the cursor is what stops the watcher from ever looking at that chat
98
+ * again. A postponed chat goes to `noteDeferral` instead.
99
+ */
100
+ export function settleChat(state: WatchState, chatId: string, marker: string): void {
101
+ state.seen.set(chatId, marker);
102
+ state.retryAfter.delete(chatId);
103
+ }
104
+
105
+ /**
106
+ * Keep a chat open — and out of the batch — until `until`.
107
+ *
108
+ * Not writing the cursor is what keeps the message; this is only the throttle
109
+ * on how often the chat is re-read while it waits.
110
+ */
111
+ export function noteDeferral(state: WatchState, chatId: string, until: number): void {
112
+ state.retryAfter.set(chatId, until);
113
+ }
114
+
115
+ /** How many chats are waiting out a cooldown or the wake limit right now. */
116
+ export function waitingCount(state: WatchState, now: number): number {
117
+ let waiting = 0;
118
+ for (const until of state.retryAfter.values()) if (until > now) waiting += 1;
119
+ return waiting;
71
120
  }
72
121
 
73
122
  /** Record a failed examination; true while the chat is still worth retrying. */
@@ -122,6 +171,12 @@ export function pruneState(state: WatchState, now: number): void {
122
171
  state.seen = new Map(byMarker.slice(0, MAX_CURSOR_ENTRIES));
123
172
  }
124
173
 
174
+ // Waiting chats expire by themselves — a deferral can never outlive the hour
175
+ // the wake limit looks at, so there is nothing to bound here.
176
+ for (const [chatId, until] of state.retryAfter) {
177
+ if (until <= now) state.retryAfter.delete(chatId);
178
+ }
179
+
125
180
  // Counters are bounded by size rather than by the cursor: a chat that keeps
126
181
  // failing is deliberately *not* in the cursor, and its counter is what stops
127
182
  // it from being retried forever.
@@ -149,6 +204,7 @@ export function chatsToExamine(
149
204
  state: WatchState,
150
205
  chats: ChatSummary[],
151
206
  watch: ResolvedWatchConfig,
207
+ now: number,
152
208
  ): ChatSummary[] {
153
209
  return chats.filter((chat) => {
154
210
  if (!isWatchedChat(chat, watch)) return false;
@@ -158,7 +214,15 @@ export function chatsToExamine(
158
214
  // "new" — and never marked, which costs no Graph call at all.
159
215
  if (!marker) return false;
160
216
 
161
- return state.seen.get(chat.id) !== marker;
217
+ if (state.seen.get(chat.id) === marker) return false;
218
+
219
+ // Moved, but the decision on it is only postponed: the chat is still open
220
+ // (it is not in the cursor), but looking at it again right now would only
221
+ // re-read the same message. Deliberately after the cursor check, and
222
+ // deliberately out of the batch rather than in it — a chat waiting out its
223
+ // cooldown must not cost another chat its slot in the tick.
224
+ const until = state.retryAfter.get(chat.id);
225
+ return until === undefined || until <= now;
162
226
  });
163
227
  }
164
228
 
@@ -244,11 +308,39 @@ export function openMessages(
244
308
  // Whether a message should wake pi
245
309
  // ---------------------------------------------------------------------------
246
310
 
247
- export type WakeDecision = { wake: true } | { wake: false; reason: string };
311
+ export type WakeDecision =
312
+ | { wake: true }
313
+ | {
314
+ wake: false;
315
+ reason: string;
316
+ /**
317
+ * Set when the skip is only for now: ms to wait before looking again.
318
+ * Absent means the decision is final — this message is not for pi.
319
+ */
320
+ retryAfterMs?: number;
321
+ };
248
322
 
249
323
  const WAKE: WakeDecision = { wake: true };
324
+
325
+ /** Final: this message will never be one of pi's. */
250
326
  const skip = (reason: string): WakeDecision => ({ wake: false, reason });
251
327
 
328
+ /** Only for now: the message stays open and is looked at again after the wait. */
329
+ const defer = (reason: string, retryAfterMs: number): WakeDecision => ({
330
+ wake: false,
331
+ reason,
332
+ retryAfterMs: Math.max(MIN_RETRY_MS, Math.round(retryAfterMs)),
333
+ });
334
+
335
+ /**
336
+ * Floor on a retry delay.
337
+ *
338
+ * A cooldown that is a second away, or a wake limit about to slide, would
339
+ * otherwise turn the deferral into "examine this chat again on every tick" —
340
+ * the point of waiting is to not pay for the same Graph calls until it matters.
341
+ */
342
+ const MIN_RETRY_MS = 5_000;
343
+
252
344
  /**
253
345
  * Is the newest message in this chat still unread for the user?
254
346
  *
@@ -275,8 +367,13 @@ export function isUnread(chat: ChatSummary, lastMessageReadAt?: string): boolean
275
367
  * Should this message wake pi?
276
368
  *
277
369
  * Ordered so the cheapest and most obvious reason to stay quiet is also the
278
- * one that gets reported — a wrong wake costs a model turn, a wrong silence
279
- * costs nothing but the reason in the log.
370
+ * one that gets reported.
371
+ *
372
+ * The two kinds of "no" are told apart here, and it matters: a message from
373
+ * somebody pi does not listen to will never become an answer, while a chat in
374
+ * its cooldown or a burst over the hourly limit is only waiting. Those two are
375
+ * returned with a `retryAfterMs`, and the loop uses it to keep the chat open
376
+ * instead of writing it off in the cursor.
280
377
  */
281
378
  export function shouldWake(
282
379
  chat: ChatSummary,
@@ -293,17 +390,34 @@ export function shouldWake(
293
390
  if (!isWatchedSender(message, watch)) return skip("the sender is outside the configured listen list");
294
391
  if (watch.mentionOnly && !mentionsMe(message, me)) return skip("the chat is set to mentions only");
295
392
  if (watch.maxTriggersPerHour > 0 && wakesThisHour(state, now) >= watch.maxTriggersPerHour) {
296
- return skip("the hourly wake limit is reached");
393
+ return defer(
394
+ "the hourly wake limit is reached",
395
+ nextWakeSlot(state, now, watch.maxTriggersPerHour) - now,
396
+ );
297
397
  }
298
398
 
299
399
  const last = state.lastTriggered.get(chat.id);
300
400
  if (last !== undefined && now - last < watch.cooldownSeconds * 1000) {
301
- return skip("this chat is inside its cooldown");
401
+ return defer("this chat is inside its cooldown", last + watch.cooldownSeconds * 1000 - now);
302
402
  }
303
403
 
304
404
  return WAKE;
305
405
  }
306
406
 
407
+ /**
408
+ * When the next wake fits under the hourly limit.
409
+ *
410
+ * The limit is a sliding window, not a bucket that empties on the hour, so the
411
+ * answer is "when the oldest wake of the last hour falls out of it". That is
412
+ * what keeps a burst — a Monday morning, a release — from silencing a chat for
413
+ * the rest of the hour.
414
+ */
415
+ export function nextWakeSlot(state: WatchState, now: number, maxPerHour: number): number {
416
+ const recent = state.triggers.filter((at) => now - at < 3600_000).sort((a, b) => a - b);
417
+ if (maxPerHour < 1 || recent.length < maxPerHour) return now;
418
+ return recent[recent.length - maxPerHour] + 3600_000;
419
+ }
420
+
307
421
  /**
308
422
  * Record that pi was woken for a chat.
309
423
  *
package/src/watch/loop.ts CHANGED
@@ -30,12 +30,15 @@ import {
30
30
  chatsToExamine,
31
31
  createWatchState,
32
32
  isUnread,
33
+ noteDeferral,
33
34
  noteFailure,
34
35
  noteSuccess,
35
36
  noteWake,
36
37
  openMessages,
37
38
  pruneState,
39
+ settleChat,
38
40
  shouldWake,
41
+ waitingCount,
39
42
  wakesThisHour,
40
43
  WATCH_MESSAGE_WINDOW,
41
44
  type WatchState,
@@ -78,6 +81,12 @@ export interface WatchRuntimeStatus {
78
81
  wakesThisHour: number;
79
82
  /** Chats seen so far — a quick sanity signal that the loop is alive */
80
83
  trackedChats: number;
84
+ /**
85
+ * Chats still open but held back right now — inside their cooldown, or waiting
86
+ * for a slot under the hourly limit. The visible form of "not lost, not yet
87
+ * answered": without it, a burst that hits the limit looks like silence.
88
+ */
89
+ waiting: number;
81
90
  /** How many chats had moved when the watcher came up, before the per-tick cap */
82
91
  catchUp?: number;
83
92
  /** Of those, how many the user had already read in Teams */
@@ -132,6 +141,8 @@ export interface WatchTickResult {
132
141
  pending: number;
133
142
  /** Of the examined ones, how many the user had already read */
134
143
  alreadyRead: number;
144
+ /** Of the examined ones, how many were only postponed */
145
+ deferred: number;
135
146
  wakes: WatchEvent[];
136
147
  }
137
148
 
@@ -143,6 +154,12 @@ export interface WatchTickResult {
143
154
  * without one every chat is compared against the user's own read state on the
144
155
  * very first tick. What keeps that from becoming a flood is the per-tick cap
145
156
  * and the hourly wake limit, not a window that quietly drops messages.
157
+ *
158
+ * The one rule the whole function hangs on: **the cursor is written only for a
159
+ * decision that is final.** It is the record of what pi has looked at, so what
160
+ * lands in it is never looked at again — which is right for a message from
161
+ * somebody pi does not listen to, and wrong for a message that was merely too
162
+ * early. Those go to `noteDeferral` instead and keep their place in line.
146
163
  */
147
164
  export async function runWatchTick(
148
165
  deps: WatchTickDeps,
@@ -155,17 +172,22 @@ export async function runWatchTick(
155
172
 
156
173
  const chats = (await deps.listChats()).filter((chat) => deps.allowed?.(chat) ?? true);
157
174
 
158
- const candidates = chatsToExamine(state, chats, watch);
175
+ const candidates = chatsToExamine(state, chats, watch, now);
159
176
  const batch = candidates.slice(0, MAX_EXAMINATIONS_PER_TICK);
160
177
 
161
178
  const wakes: WatchEvent[] = [];
162
179
  let alreadyRead = 0;
180
+ let deferred = 0;
163
181
 
164
182
  for (const chat of batch) {
165
- // Mark first, then undo the mark if this chat cannot be examined: a chat
166
- // that fails must not be retried forever, but a token renewal must not turn
167
- // into a message nobody ever answers.
168
- state.seen.set(chat.id, activityMarker(chat));
183
+ const marker = activityMarker(chat);
184
+
185
+ // A chat that cannot be read must not occupy a batch slot on every tick, and
186
+ // a token renewal must not turn into a message nobody ever answers: it is
187
+ // written off only once it has used up its attempts.
188
+ const failed = () => {
189
+ if (!noteFailure(state, chat.id)) settleChat(state, chat.id, marker);
190
+ };
169
191
 
170
192
  // The read cursor first: a chat the user has already opened in Teams ends
171
193
  // here, one Graph call in, before the message is even fetched.
@@ -173,12 +195,13 @@ export async function runWatchTick(
173
195
  try {
174
196
  readAt = await deps.readState(chat.id);
175
197
  } catch {
176
- if (noteFailure(state, chat.id)) state.seen.delete(chat.id);
198
+ failed();
177
199
  continue;
178
200
  }
179
201
 
180
202
  if (!isUnread(chat, readAt)) {
181
203
  noteSuccess(state, chat.id);
204
+ settleChat(state, chat.id, marker);
182
205
  alreadyRead += 1;
183
206
  continue;
184
207
  }
@@ -189,19 +212,33 @@ export async function runWatchTick(
189
212
  } catch {
190
213
  // A single unreadable chat must not end the tick — the rest of the
191
214
  // list is still worth examining.
192
- if (noteFailure(state, chat.id)) state.seen.delete(chat.id);
215
+ failed();
193
216
  continue;
194
217
  }
195
218
 
196
219
  noteSuccess(state, chat.id);
197
220
 
198
221
  const message = messages.find((entry) => !entry.deletedDateTime) ?? messages[0];
199
- if (!message) continue;
222
+ if (!message) {
223
+ settleChat(state, chat.id, marker);
224
+ continue;
225
+ }
200
226
 
201
227
  const decision = shouldWake(chat, message, me, watch, state, now);
202
- if (!decision.wake) continue;
228
+ if (!decision.wake) {
229
+ if (decision.retryAfterMs !== undefined) {
230
+ // Postponed, not answered and not written off: the chat keeps its
231
+ // message and waits out the cooldown or the wake limit.
232
+ noteDeferral(state, chat.id, now + decision.retryAfterMs);
233
+ deferred += 1;
234
+ } else {
235
+ settleChat(state, chat.id, marker);
236
+ }
237
+ continue;
238
+ }
203
239
 
204
240
  noteWake(state, chat.id, now);
241
+ settleChat(state, chat.id, marker);
205
242
 
206
243
  // The answer has to cover the whole open thread, not just its newest line:
207
244
  // the trigger message is the reason for the wake, the backlog is the reason
@@ -217,10 +254,10 @@ export async function runWatchTick(
217
254
  }
218
255
 
219
256
  // Written even when nothing woke: marking a chat examined is the progress
220
- // that must survive a restart.
257
+ // that must survive a restart. Postponed chats are absent from it on purpose.
221
258
  if (batch.length > 0) deps.persistCursor?.(state.seen);
222
259
 
223
- return { examined: batch.length, pending: candidates.length, alreadyRead, wakes };
260
+ return { examined: batch.length, pending: candidates.length, alreadyRead, deferred, wakes };
224
261
  }
225
262
 
226
263
  // ---------------------------------------------------------------------------
@@ -301,6 +338,7 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
301
338
  intervalSeconds: watch.intervalSeconds,
302
339
  wakesThisHour: 0,
303
340
  trackedChats: 0,
341
+ waiting: 0,
304
342
  };
305
343
 
306
344
  const schedule = (delayMs: number) => {
@@ -346,6 +384,7 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
346
384
  status.lastError = undefined;
347
385
  status.wakesThisHour = wakesThisHour(state, status.lastTickAt);
348
386
  status.trackedChats = state.seen.size;
387
+ status.waiting = waitingCount(state, status.lastTickAt);
349
388
 
350
389
  for (const event of result.wakes) {
351
390
  status.lastWakeAt = event.wokeAt;
@@ -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
- "Format it for a chat bubble, not a document: the answer first, three short paragraphs at most, " +
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");