@patimweb/pi-teams 1.14.0 → 1.15.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/docs/listen-mode.md +82 -0
- package/docs/tools.md +2 -0
- package/package.json +1 -1
- package/src/config/index.ts +114 -0
- package/src/extension/index.ts +118 -7
- package/src/tools/teams-history.ts +152 -0
- package/src/tools/teams-watch.ts +5 -0
- package/src/watch/control.ts +60 -0
- package/src/watch/dispatch.ts +577 -0
- package/src/watch/pin.ts +62 -0
- package/src/watch/worker.ts +78 -0
package/docs/listen-mode.md
CHANGED
|
@@ -50,6 +50,7 @@ never sees.
|
|
|
50
50
|
| `mentionOnly` | `false` | whether a message has to address you: `true`/`false`, or `{ default, chats, people }` for overrides. See [below](#who-has-to-address-pi). |
|
|
51
51
|
| `cooldownSeconds` | `300` | stay quiet in a chat after waking pi for it |
|
|
52
52
|
| `maxTriggersPerHour` | `10` | hard cap on wakes per hour |
|
|
53
|
+
| `dispatch` | `{ "mode": "session" }` | where a wake is answered: in this session, or by one pi process per chat. See [below](#one-pi-process-per-chat). |
|
|
53
54
|
|
|
54
55
|
Set it globally, or per account — the account level wins field by field.
|
|
55
56
|
`teams_watch` writes the same keys; `/teams-listen` writes to the session's
|
|
@@ -152,6 +153,87 @@ untouched, because a gate that blocked ordinary work would be met far more often
|
|
|
152
153
|
than an attack. It is released when the turn ends, and expires by itself after
|
|
153
154
|
15 minutes so a turn that never ends cannot lock you out of your own tools.
|
|
154
155
|
|
|
156
|
+
The pin is taken when the wake's prompt actually **enters the transcript**, not
|
|
157
|
+
when the message is picked up. A wake that arrives while pi is still answering
|
|
158
|
+
another chat waits in the queue; pinning it right away would take the pin away
|
|
159
|
+
from the answer that is still being written, and that answer would be refused.
|
|
160
|
+
|
|
161
|
+
In [dispatch mode](#one-pi-process-per-chat) every worker is pinned to its chat
|
|
162
|
+
for its whole life: it exists to answer that one conversation.
|
|
163
|
+
|
|
164
|
+
## One pi process per chat
|
|
165
|
+
|
|
166
|
+
By default every wake is a follow-up in the session that runs the watcher. That
|
|
167
|
+
session works on one chat at a time: while it answers Anna, Bob waits, and when
|
|
168
|
+
Anna asked for something that takes twenty minutes, Bob waits twenty minutes.
|
|
169
|
+
|
|
170
|
+
`dispatch.mode: "process"` hands every chat a pi process of its own:
|
|
171
|
+
|
|
172
|
+
```json
|
|
173
|
+
"watch": {
|
|
174
|
+
"dispatch": {
|
|
175
|
+
"mode": "process",
|
|
176
|
+
"maxConcurrent": 3,
|
|
177
|
+
"maxWorkers": 6,
|
|
178
|
+
"idleMinutes": 30,
|
|
179
|
+
"freshAfterHours": 72,
|
|
180
|
+
"args": ["--model", "provider/model-id"],
|
|
181
|
+
"stopWords": ["stop", "stopp", "abbrechen"],
|
|
182
|
+
"resetWords": ["neues thema", "new topic"],
|
|
183
|
+
"historyReaders": ["you@contoso.com"]
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
| Key | Default | Meaning |
|
|
189
|
+
|-----|---------|---------|
|
|
190
|
+
| `mode` | `"session"` | `"process"` switches the worker pool on |
|
|
191
|
+
| `maxConcurrent` | `3` | chats worked on at the same time (1-16); more wait in line |
|
|
192
|
+
| `maxWorkers` | `6` | processes kept alive, busy or idle; the longest-idle one makes room |
|
|
193
|
+
| `idleMinutes` | `30` | an idle worker exits after this; its session stays on disk |
|
|
194
|
+
| `freshAfterHours` | `72` | a chat quiet for longer starts a fresh session (`0` = always continue) |
|
|
195
|
+
| `command` | `"pi"` | the pi executable |
|
|
196
|
+
| `args` | `[]` | extra arguments for every worker, e.g. the model |
|
|
197
|
+
| `stopWords` | `stop, stopp, abbrechen, abbruch, cancel` | a message that is only this word aborts the running work |
|
|
198
|
+
| `resetWords` | `neues thema, new topic, reset` | a message that is only this word starts the chat over |
|
|
199
|
+
| `historyReaders` | `[]` | who may use `teams_history` from their one-to-one chat with pi |
|
|
200
|
+
|
|
201
|
+
How it behaves:
|
|
202
|
+
|
|
203
|
+
| In Teams | What happens |
|
|
204
|
+
|----------|--------------|
|
|
205
|
+
| Messages in different chats | answered in parallel, each by its chat's worker |
|
|
206
|
+
| Next message in the same chat | the chat's session continues, with the whole conversation so far |
|
|
207
|
+
| A message while the worker is busy | steered into the running turn: read after the current step, not after the task |
|
|
208
|
+
| `stopp` | the running turn is aborted; the model confirms in one line |
|
|
209
|
+
| `neues Thema` | the worker ends, the chat starts a fresh session; the old one stays on disk |
|
|
210
|
+
| More busy chats than `maxConcurrent` | the next chat waits for a free slot; a newer message replaces its waiting one |
|
|
211
|
+
|
|
212
|
+
Each worker runs `pi --mode rpc --session-dir <agent dir>/pi-teams-chats/<hash>`
|
|
213
|
+
with `--continue` while the chat's last session is recent. It loads the same
|
|
214
|
+
extensions and config as any pi, with three differences set through its
|
|
215
|
+
environment (`PI_TEAMS_WORKER_CHAT` and friends): it never starts a watcher, it
|
|
216
|
+
may write to its own chat only, and `teams_watch`, `teams_setup` and
|
|
217
|
+
`teams_logout` are closed to it. Dialogs a worker would show are cancelled,
|
|
218
|
+
because nobody sits in front of it, so run it with `safetyLevel: "open"` and
|
|
219
|
+
keep the guardrails in `from`, the pin and the permission rules.
|
|
220
|
+
|
|
221
|
+
The watching session only routes. `/teams-dispatch` shows which chats are being
|
|
222
|
+
worked on, which are idle and which wait.
|
|
223
|
+
|
|
224
|
+
### What pi said in other chats
|
|
225
|
+
|
|
226
|
+
Separate sessions mean that no single context knows what pi told somebody else.
|
|
227
|
+
Two logs do, and `teams_history` reads them:
|
|
228
|
+
|
|
229
|
+
- `kind: "sent"`: every message pi sent, from the audit log (`audit: true`).
|
|
230
|
+
- `kind: "dispatch"`: every request a worker picked up and a short form of its
|
|
231
|
+
answer, from `pi-teams-dispatch.jsonl` in the agent directory.
|
|
232
|
+
|
|
233
|
+
Nothing from one chat reaches another chat's context by itself. Inside a worker
|
|
234
|
+
the tool answers only in a one-to-one chat with a person listed in
|
|
235
|
+
`historyReaders`; the watching session is never restricted.
|
|
236
|
+
|
|
155
237
|
## Cost
|
|
156
238
|
|
|
157
239
|
Each wake costs a model turn. That is what the filters and the hourly cap are
|
package/docs/tools.md
CHANGED
|
@@ -21,6 +21,7 @@ the default account. See [configuration.md](configuration.md).
|
|
|
21
21
|
| Tool | Description |
|
|
22
22
|
|------|-------------|
|
|
23
23
|
| `teams_inbox` | Chats with new messages plus recent mentions of you |
|
|
24
|
+
| `teams_history` | What pi sent across chats (audit log), or what the per-chat workers were asked and answered (dispatch journal). Read-only; see [listen mode](listen-mode.md#what-pi-said-in-other-chats) |
|
|
24
25
|
| `teams_list_chats` | Recent chats with a preview of the last message |
|
|
25
26
|
| `teams_read_chat` | Messages of one chat |
|
|
26
27
|
| `teams_list_teams` | Teams you belong to |
|
|
@@ -74,6 +75,7 @@ usual; correcting a channel post stays in the Teams client.
|
|
|
74
75
|
| `/teams-login` | Sign in to Teams as yourself |
|
|
75
76
|
| `/teams-inbox` | What needs your attention right now |
|
|
76
77
|
| `/teams-listen` | Listen mode: `on`, `off`, or `status` |
|
|
78
|
+
| `/teams-dispatch` | Listen mode with one pi process per chat: who is being worked on, who is idle, who waits |
|
|
77
79
|
| `/teams-permissions` | What pi may and may not do |
|
|
78
80
|
| `/teams-doctor` | Diagnose the setup |
|
|
79
81
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patimweb/pi-teams",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.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",
|
package/src/config/index.ts
CHANGED
|
@@ -135,6 +135,70 @@ export interface WatchConfig {
|
|
|
135
135
|
cooldownSeconds?: number;
|
|
136
136
|
/** Hard cap on wakes per hour, across all chats (default: 10) */
|
|
137
137
|
maxTriggersPerHour?: number;
|
|
138
|
+
/**
|
|
139
|
+
* How a wake reaches a model. See `DispatchConfig`. Absent means the
|
|
140
|
+
* classic behaviour: every wake is a turn in this pi session.
|
|
141
|
+
*/
|
|
142
|
+
dispatch?: DispatchConfig;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Where the answer to a wake is worked out.
|
|
147
|
+
*
|
|
148
|
+
* `session` (default) queues every wake as a follow-up in the pi session that
|
|
149
|
+
* runs the watcher. One chat at a time: while pi works on one request, every
|
|
150
|
+
* other chat waits.
|
|
151
|
+
*
|
|
152
|
+
* `process` gives every chat a pi process of its own (`pi --mode rpc`), with a
|
|
153
|
+
* session of its own that carries on from message to message. Different chats
|
|
154
|
+
* are answered in parallel; messages in the same chat stay in order, and one
|
|
155
|
+
* that arrives while the worker is busy is steered into the running turn. The
|
|
156
|
+
* watching session itself only routes.
|
|
157
|
+
*/
|
|
158
|
+
export interface DispatchConfig {
|
|
159
|
+
/** "session" (default) or "process" */
|
|
160
|
+
mode?: DispatchMode;
|
|
161
|
+
/** Chats worked on at the same time (default: 3, 1-16) */
|
|
162
|
+
maxConcurrent?: number;
|
|
163
|
+
/** Worker processes kept alive, busy or idle (default: 6, at least maxConcurrent, max 32) */
|
|
164
|
+
maxWorkers?: number;
|
|
165
|
+
/** An idle worker exits after this many minutes; its session stays (default: 30) */
|
|
166
|
+
idleMinutes?: number;
|
|
167
|
+
/**
|
|
168
|
+
* A chat quiet for longer than this starts a fresh session instead of
|
|
169
|
+
* continuing the last one (default: 72, 0 = always continue).
|
|
170
|
+
*/
|
|
171
|
+
freshAfterHours?: number;
|
|
172
|
+
/** The pi executable (default: "pi") */
|
|
173
|
+
command?: string;
|
|
174
|
+
/** Extra arguments for every worker, e.g. ["--model", "provider/id"] */
|
|
175
|
+
args?: string[];
|
|
176
|
+
/** A message consisting of one of these aborts the running work (case-insensitive) */
|
|
177
|
+
stopWords?: string[];
|
|
178
|
+
/** A message consisting of one of these starts a fresh session for the chat */
|
|
179
|
+
resetWords?: string[];
|
|
180
|
+
/**
|
|
181
|
+
* People who may, from their one-to-one chat with pi, read what pi wrote in
|
|
182
|
+
* other chats (`teams_history`). Matched like `from`. Empty means nobody;
|
|
183
|
+
* the watching session itself is never restricted.
|
|
184
|
+
*/
|
|
185
|
+
historyReaders?: string[];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export type DispatchMode = "session" | "process";
|
|
189
|
+
|
|
190
|
+
/** A dispatch config after every default and clamp has been applied. */
|
|
191
|
+
export interface ResolvedDispatchConfig {
|
|
192
|
+
mode: DispatchMode;
|
|
193
|
+
maxConcurrent: number;
|
|
194
|
+
maxWorkers: number;
|
|
195
|
+
idleMinutes: number;
|
|
196
|
+
freshAfterHours: number;
|
|
197
|
+
command: string;
|
|
198
|
+
args: string[];
|
|
199
|
+
stopWords: string[];
|
|
200
|
+
resetWords: string[];
|
|
201
|
+
historyReaders: string[];
|
|
138
202
|
}
|
|
139
203
|
|
|
140
204
|
/**
|
|
@@ -189,6 +253,7 @@ export interface ResolvedWatchConfig {
|
|
|
189
253
|
mentionOnly: ResolvedMentionOnly;
|
|
190
254
|
cooldownSeconds: number;
|
|
191
255
|
maxTriggersPerHour: number;
|
|
256
|
+
dispatch: ResolvedDispatchConfig;
|
|
192
257
|
}
|
|
193
258
|
|
|
194
259
|
/**
|
|
@@ -384,6 +449,25 @@ export const WATCH_DEFAULTS: ResolvedWatchConfig = {
|
|
|
384
449
|
mentionOnly: { default: false, chats: [], people: [] },
|
|
385
450
|
cooldownSeconds: 300,
|
|
386
451
|
maxTriggersPerHour: 10,
|
|
452
|
+
dispatch: {
|
|
453
|
+
mode: "session",
|
|
454
|
+
maxConcurrent: 3,
|
|
455
|
+
maxWorkers: 6,
|
|
456
|
+
idleMinutes: 30,
|
|
457
|
+
freshAfterHours: 72,
|
|
458
|
+
command: "pi",
|
|
459
|
+
args: [],
|
|
460
|
+
stopWords: ["stop", "stopp", "abbrechen", "abbruch", "cancel"],
|
|
461
|
+
resetWords: ["neues thema", "new topic", "reset"],
|
|
462
|
+
historyReaders: [],
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const DISPATCH_BOUNDS = {
|
|
467
|
+
maxConcurrent: { min: 1, max: 16 },
|
|
468
|
+
maxWorkers: { min: 1, max: 32 },
|
|
469
|
+
idleMinutes: { min: 1, max: 1440 },
|
|
470
|
+
freshAfterHours: { min: 0, max: 8760 },
|
|
387
471
|
};
|
|
388
472
|
|
|
389
473
|
/**
|
|
@@ -802,6 +886,36 @@ export function resolveWatchConfig(
|
|
|
802
886
|
WATCH_BOUNDS.maxTriggersPerHour,
|
|
803
887
|
WATCH_DEFAULTS.maxTriggersPerHour,
|
|
804
888
|
),
|
|
889
|
+
dispatch: resolveDispatchConfig(pick("dispatch")),
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Normalise the dispatch settings. Exported for tests.
|
|
895
|
+
*
|
|
896
|
+
* `maxWorkers` is raised to `maxConcurrent` when set lower: a worker that is
|
|
897
|
+
* busy is alive, so fewer live workers than parallel chats is not a limit but
|
|
898
|
+
* a contradiction.
|
|
899
|
+
*/
|
|
900
|
+
export function resolveDispatchConfig(value: DispatchConfig | undefined): ResolvedDispatchConfig {
|
|
901
|
+
const d = WATCH_DEFAULTS.dispatch;
|
|
902
|
+
const v = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
903
|
+
const words = (list: unknown, fallback: string[]): string[] =>
|
|
904
|
+
Array.isArray(list)
|
|
905
|
+
? list.filter((w): w is string => typeof w === "string" && w.trim() !== "").map((w) => w.trim().toLowerCase())
|
|
906
|
+
: fallback;
|
|
907
|
+
const maxConcurrent = clamp(v.maxConcurrent, DISPATCH_BOUNDS.maxConcurrent, d.maxConcurrent);
|
|
908
|
+
return {
|
|
909
|
+
mode: v.mode === "process" ? "process" : "session",
|
|
910
|
+
maxConcurrent,
|
|
911
|
+
maxWorkers: Math.max(maxConcurrent, clamp(v.maxWorkers, DISPATCH_BOUNDS.maxWorkers, d.maxWorkers)),
|
|
912
|
+
idleMinutes: clamp(v.idleMinutes, DISPATCH_BOUNDS.idleMinutes, d.idleMinutes),
|
|
913
|
+
freshAfterHours: clamp(v.freshAfterHours, DISPATCH_BOUNDS.freshAfterHours, d.freshAfterHours),
|
|
914
|
+
command: typeof v.command === "string" && v.command.trim() ? v.command.trim() : d.command,
|
|
915
|
+
args: Array.isArray(v.args) ? v.args.filter((a): a is string => typeof a === "string") : [...d.args],
|
|
916
|
+
stopWords: words(v.stopWords, d.stopWords),
|
|
917
|
+
resetWords: words(v.resetWords, d.resetWords),
|
|
918
|
+
historyReaders: normalizePatterns(v.historyReaders),
|
|
805
919
|
};
|
|
806
920
|
}
|
|
807
921
|
|
package/src/extension/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { Box, Text } from "@earendil-works/pi-tui";
|
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
ensureConfigTemplate,
|
|
14
|
+
getAgentDir,
|
|
14
15
|
getConfigPath,
|
|
15
16
|
setWatchConfig,
|
|
16
17
|
type ResolvedMentionOnly,
|
|
@@ -38,7 +39,11 @@ import {
|
|
|
38
39
|
} from "../watch/loop.ts";
|
|
39
40
|
import { composeWatchPrompt } from "../watch/prompt.ts";
|
|
40
41
|
import { setChatReadState } from "../graph/chats.ts";
|
|
41
|
-
import { clearWakeTarget, pinViolation,
|
|
42
|
+
import { claimWakePin, clearWakeTarget, pinViolation, queueWakePin } from "../watch/pin.ts";
|
|
43
|
+
import { ChatDispatcher, excerpt } from "../watch/dispatch.ts";
|
|
44
|
+
import { controlAction } from "../watch/control.ts";
|
|
45
|
+
import { workerIdentity, workerViolation } from "../watch/worker.ts";
|
|
46
|
+
import { withSenderAddresses } from "../watch/index.ts";
|
|
42
47
|
import { readWatchCursor, writeWatchCursor } from "../watch/cursor.ts";
|
|
43
48
|
|
|
44
49
|
import { teamsSetupTool } from "../tools/teams-setup.ts";
|
|
@@ -71,6 +76,7 @@ import { teamsChatMembersTool } from "../tools/teams-chat-members.ts";
|
|
|
71
76
|
import { teamsAvailabilityTool } from "../tools/teams-availability.ts";
|
|
72
77
|
import { teamsDeleteMessageTool } from "../tools/teams-delete-message.ts";
|
|
73
78
|
import { teamsInboxTool } from "../tools/teams-inbox.ts";
|
|
79
|
+
import { teamsHistoryTool } from "../tools/teams-history.ts";
|
|
74
80
|
import { teamsWatchTool } from "../tools/teams-watch.ts";
|
|
75
81
|
import {
|
|
76
82
|
teamsGetPresenceTool,
|
|
@@ -137,6 +143,7 @@ const tools = [
|
|
|
137
143
|
teamsUpdateMessageTool,
|
|
138
144
|
teamsDeleteMessageTool,
|
|
139
145
|
teamsInboxTool,
|
|
146
|
+
teamsHistoryTool,
|
|
140
147
|
// Presence
|
|
141
148
|
teamsGetPresenceTool,
|
|
142
149
|
teamsSetPresenceTool,
|
|
@@ -178,6 +185,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
178
185
|
*/
|
|
179
186
|
let watchSignature: string | undefined;
|
|
180
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Set when this process is a chat worker started by a dispatcher: it answers
|
|
190
|
+
* one chat, never watches, and may write nowhere else (see ../watch/worker.ts).
|
|
191
|
+
*/
|
|
192
|
+
const worker = workerIdentity();
|
|
193
|
+
|
|
194
|
+
/** The per-chat worker pool, in dispatch mode `process`. Created on the first wake. */
|
|
195
|
+
let dispatcher: ChatDispatcher | undefined;
|
|
196
|
+
/** The context the dispatcher reports problems to. */
|
|
197
|
+
let dispatchCtx: any;
|
|
198
|
+
|
|
199
|
+
const dispatcherFor = (conn: TeamsConnection, ctx: any): ChatDispatcher => {
|
|
200
|
+
dispatchCtx = ctx;
|
|
201
|
+
if (dispatcher) {
|
|
202
|
+
dispatcher.configure(conn.watch.dispatch);
|
|
203
|
+
return dispatcher;
|
|
204
|
+
}
|
|
205
|
+
dispatcher = new ChatDispatcher({
|
|
206
|
+
config: conn.watch.dispatch,
|
|
207
|
+
agentDir: getAgentDir(),
|
|
208
|
+
cwd: ctx?.cwd ?? process.cwd(),
|
|
209
|
+
onError: (message) => dispatchCtx?.ui?.notify?.(message, "warning"),
|
|
210
|
+
});
|
|
211
|
+
return dispatcher;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const stopDispatcher = () => {
|
|
215
|
+
dispatcher?.stopAll();
|
|
216
|
+
dispatcher = undefined;
|
|
217
|
+
};
|
|
218
|
+
|
|
181
219
|
/** The last polling error the user was told about, so it is said once. */
|
|
182
220
|
let reportedWatchError: string | undefined;
|
|
183
221
|
|
|
@@ -222,10 +260,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
222
260
|
cursor: readWatchCursor(conn.account, conn.tenantId),
|
|
223
261
|
persistCursor: (seen) => writeWatchCursor(conn.account, conn.tenantId, seen),
|
|
224
262
|
onWake: (event) => {
|
|
225
|
-
// Pin before the prompt is delivered: the turn it starts may only
|
|
226
|
-
// answer the chat that woke pi, and the message it is about to read
|
|
227
|
-
// was written by somebody else.
|
|
228
|
-
pinWakeTarget(event.chat.id, event.chat.label);
|
|
229
263
|
// Mark the chat read the moment pi picks it up: with read receipts on,
|
|
230
264
|
// the sender sees the "seen" eye right away — the closest thing to a
|
|
231
265
|
// typing indicator Graph offers (there is no typing API for users).
|
|
@@ -233,7 +267,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
233
267
|
if (event.me?.id) {
|
|
234
268
|
void setChatReadState(conn, event.chat.id, event.me, true).catch(() => undefined);
|
|
235
269
|
}
|
|
236
|
-
|
|
270
|
+
const prompt = composeWatchPrompt(event, event.me);
|
|
271
|
+
|
|
272
|
+
// Dispatch mode: the chat's own worker answers, in parallel with other chats.
|
|
273
|
+
if (conn.watch.dispatch.mode === "process") {
|
|
274
|
+
const message = withSenderAddresses(event.message, event.chat);
|
|
275
|
+
dispatcherFor(conn, ctx).deliver({
|
|
276
|
+
chatId: event.chat.id,
|
|
277
|
+
label: event.chat.label,
|
|
278
|
+
chatType: event.chat.chatType,
|
|
279
|
+
from: message.from?.displayName,
|
|
280
|
+
peer: message.from?.mail ?? message.from?.upn,
|
|
281
|
+
prompt,
|
|
282
|
+
request: excerpt(message.text),
|
|
283
|
+
control: controlAction(message, conn.watch.dispatch, event.me?.displayName),
|
|
284
|
+
});
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Session mode: the turn this prompt starts may only answer the chat
|
|
289
|
+
// that woke pi. The pin is taken when the prompt enters the transcript,
|
|
290
|
+
// not now: while pi is busy the prompt waits in the queue, and pinning
|
|
291
|
+
// here would take the pin away from the answer still being written.
|
|
292
|
+
queueWakePin(prompt, event.chat.id, event.chat.label);
|
|
293
|
+
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
237
294
|
},
|
|
238
295
|
onTick: (status) => {
|
|
239
296
|
if (!status.lastError) reportedWatchError = undefined;
|
|
@@ -384,12 +441,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
384
441
|
conn.watch.from.length > 0 ? `people: ${conn.watch.from.join(", ")}` : "any sender",
|
|
385
442
|
mentionSummary(conn.watch.mentionOnly),
|
|
386
443
|
runtime ? `${runtime.wakesThisHour} wake(s) this hour` : "not running in this session",
|
|
444
|
+
conn.watch.dispatch.mode === "process"
|
|
445
|
+
? `one process per chat (${dispatcher?.status().busy ?? 0}/${conn.watch.dispatch.maxConcurrent} busy)`
|
|
446
|
+
: "answers in this session",
|
|
387
447
|
].join(" · "),
|
|
388
448
|
"info",
|
|
389
449
|
);
|
|
390
450
|
},
|
|
391
451
|
});
|
|
392
452
|
|
|
453
|
+
pi.registerCommand("teams-dispatch", {
|
|
454
|
+
description: "Show the per-chat workers of listen mode (dispatch mode process)",
|
|
455
|
+
handler: async (_args, ctx) => {
|
|
456
|
+
const conn = refresh();
|
|
457
|
+
if (!conn || conn.watch.dispatch.mode !== "process") {
|
|
458
|
+
ctx.ui.notify("Teams dispatch: off. Every wake is a turn in this session (watch.dispatch.mode).", "info");
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const status = dispatcher?.status();
|
|
462
|
+
if (!status || status.alive + status.queued === 0) {
|
|
463
|
+
ctx.ui.notify(`Teams dispatch: no worker running (max ${conn.watch.dispatch.maxConcurrent} in parallel).`, "info");
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const minutes = (ms: number) => `${Math.max(0, Math.round(ms / 60_000))} min`;
|
|
467
|
+
ctx.ui.notify(
|
|
468
|
+
[
|
|
469
|
+
`Teams dispatch: ${status.busy}/${status.maxConcurrent} busy, ${status.alive} alive, ${status.queued} waiting`,
|
|
470
|
+
...status.chats.map((c) => `- ${c.label}: ${c.busy ? `working for ${minutes(c.sinceMs)}` : `idle for ${minutes(c.sinceMs)}`}`),
|
|
471
|
+
...status.waiting.map((c) => `- ${c.label}: waiting for a slot`),
|
|
472
|
+
].join("\n"),
|
|
473
|
+
"info",
|
|
474
|
+
);
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
|
|
393
478
|
pi.registerCommand("teams-inbox", {
|
|
394
479
|
description: "Show what needs your attention in Microsoft Teams",
|
|
395
480
|
handler: async (_args, ctx) => {
|
|
@@ -456,7 +541,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
456
541
|
// and the same config: autoStart there would run a second watcher that
|
|
457
542
|
// answers chats inside a throwaway process and advances the shared cursor,
|
|
458
543
|
// so the real session never sees those messages.
|
|
459
|
-
|
|
544
|
+
// A chat worker (--mode rpc, so hasUI is true) is excluded by name: it is
|
|
545
|
+
// started by the watcher and must never become one.
|
|
546
|
+
if (connection.watch.autoStart && ctx.hasUI && !worker) startWatch(ctx, { explicit: true });
|
|
460
547
|
|
|
461
548
|
paintStatus(ctx);
|
|
462
549
|
|
|
@@ -515,6 +602,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
515
602
|
// The timer would be unref'd anyway, but a poll in flight during shutdown
|
|
516
603
|
// would still be a Graph call for a session that is gone.
|
|
517
604
|
stopWatch();
|
|
605
|
+
// Workers end with the session that started them; their sessions stay on disk.
|
|
606
|
+
stopDispatcher();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// A queued wake prompt has entered the transcript: its turn starts now, so
|
|
610
|
+
// this is when the answer gets pinned to its chat.
|
|
611
|
+
pi.on("message_start", async (event: any) => {
|
|
612
|
+
const message = event?.message;
|
|
613
|
+
if (message?.role !== "user") return;
|
|
614
|
+
const content = message.content;
|
|
615
|
+
const text =
|
|
616
|
+
typeof content === "string"
|
|
617
|
+
? content
|
|
618
|
+
: Array.isArray(content)
|
|
619
|
+
? content
|
|
620
|
+
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
|
621
|
+
.map((part: any) => part.text)
|
|
622
|
+
.join("")
|
|
623
|
+
: "";
|
|
624
|
+
if (text) claimWakePin(text);
|
|
518
625
|
});
|
|
519
626
|
|
|
520
627
|
// -----------------------------------------------------------------------
|
|
@@ -528,6 +635,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
528
635
|
});
|
|
529
636
|
|
|
530
637
|
pi.on("tool_call", async (event, ctx) => {
|
|
638
|
+
const workerBlock = workerViolation(event.toolName, worker);
|
|
639
|
+
if (workerBlock) return { block: true, reason: workerBlock };
|
|
640
|
+
|
|
531
641
|
if (!isMutationTool(event.toolName)) return;
|
|
532
642
|
|
|
533
643
|
// A tool may target a different account than the session default, so the
|
|
@@ -596,6 +706,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
596
706
|
// A watcher polling a session that was just removed only produces
|
|
597
707
|
// errors; leaving it running would be noise, not service.
|
|
598
708
|
stopWatch();
|
|
709
|
+
stopDispatcher();
|
|
599
710
|
} else if (WATCH_CONFIG_TOOLS.has(event.toolName)) {
|
|
600
711
|
// Only `enable` is a decision. A status call, or a re-read after
|
|
601
712
|
// teams_setup, may stop the loop but never start it.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* teams_history — what pi wrote in Teams, and what the chat workers were asked.
|
|
3
|
+
*
|
|
4
|
+
* In dispatch mode every chat has a session of its own, so no single context
|
|
5
|
+
* knows what pi said elsewhere. This tool answers "what did you tell Anna
|
|
6
|
+
* today?" from the two logs that do know: the audit log (every message pi
|
|
7
|
+
* sent, see ../safety/audit.ts) and the dispatch journal (every request a chat
|
|
8
|
+
* worker picked up, and its answer, see ../watch/dispatch.ts).
|
|
9
|
+
*
|
|
10
|
+
* Reading across chats is exactly what the per-chat sessions exist to prevent
|
|
11
|
+
* by default, so inside a chat worker the tool answers only in a one-to-one
|
|
12
|
+
* chat with somebody listed in `watch.dispatch.historyReaders`. The watching
|
|
13
|
+
* session — the user at the keyboard — is never restricted.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, openSync, readSync, closeSync, statSync } from "node:fs";
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
import { getAgentDir } from "../config/index.ts";
|
|
19
|
+
import { matchesPattern } from "../config/scope.ts";
|
|
20
|
+
import { getAuditPath } from "../safety/audit.ts";
|
|
21
|
+
import { getDispatchJournalPath } from "../watch/dispatch.ts";
|
|
22
|
+
import { workerIdentity, type WorkerIdentity } from "../watch/worker.ts";
|
|
23
|
+
import { errorResult, run, textResult, type ToolContext, type ToolResult } from "./shared.ts";
|
|
24
|
+
|
|
25
|
+
/** How much of the end of a log is read. Old entries are the least asked for. */
|
|
26
|
+
const TAIL_BYTES = 2 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
/** The last `TAIL_BYTES` of a JSONL file, parsed. Broken lines are skipped. */
|
|
29
|
+
export function readJsonlTail(path: string, maxBytes = TAIL_BYTES): Record<string, any>[] {
|
|
30
|
+
if (!existsSync(path)) return [];
|
|
31
|
+
const size = statSync(path).size;
|
|
32
|
+
const start = Math.max(0, size - maxBytes);
|
|
33
|
+
const buffer = Buffer.alloc(size - start);
|
|
34
|
+
const fd = openSync(path, "r");
|
|
35
|
+
try {
|
|
36
|
+
readSync(fd, buffer, 0, buffer.length, start);
|
|
37
|
+
} finally {
|
|
38
|
+
closeSync(fd);
|
|
39
|
+
}
|
|
40
|
+
const lines = buffer.toString("utf-8").split("\n");
|
|
41
|
+
if (start > 0) lines.shift(); // first line is cut
|
|
42
|
+
const entries: Record<string, any>[] = [];
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
if (!line.trim()) continue;
|
|
45
|
+
try {
|
|
46
|
+
entries.push(JSON.parse(line));
|
|
47
|
+
} catch {
|
|
48
|
+
/* skip */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return entries;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Why this process may not read across chats, or undefined when it may.
|
|
56
|
+
* Exported for tests.
|
|
57
|
+
*/
|
|
58
|
+
export function historyRefusal(identity: WorkerIdentity | undefined, readers: string[]): string | undefined {
|
|
59
|
+
if (!identity) return undefined;
|
|
60
|
+
const allowed =
|
|
61
|
+
identity.chatType === "oneOnOne" &&
|
|
62
|
+
!!identity.peer &&
|
|
63
|
+
readers.some((pattern) => matchesPattern(pattern, identity.peer!));
|
|
64
|
+
if (allowed) return undefined;
|
|
65
|
+
return (
|
|
66
|
+
"What pi wrote in other chats is not available in this chat. Each chat keeps its own context, and only " +
|
|
67
|
+
"the people listed in watch.dispatch.historyReaders may look across chats, from their one-to-one chat with pi."
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface HistoryParams {
|
|
72
|
+
kind?: "sent" | "dispatch";
|
|
73
|
+
chat?: string;
|
|
74
|
+
sinceHours?: number;
|
|
75
|
+
limit?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Filter and render log entries. Exported for tests. */
|
|
79
|
+
export function renderHistory(entries: Record<string, any>[], params: HistoryParams, now = Date.now()): string {
|
|
80
|
+
const kind = params.kind ?? "sent";
|
|
81
|
+
const limit = Math.min(Math.max(params.limit ?? 20, 1), 200);
|
|
82
|
+
const since = now - Math.min(Math.max(params.sinceHours ?? 24, 1), 24 * 90) * 3_600_000;
|
|
83
|
+
const needle = params.chat?.trim().toLowerCase();
|
|
84
|
+
|
|
85
|
+
const hits = entries.filter((entry) => {
|
|
86
|
+
const at = Date.parse(entry.at ?? "");
|
|
87
|
+
if (!Number.isFinite(at) || at < since) return false;
|
|
88
|
+
if (!needle) return true;
|
|
89
|
+
const haystack =
|
|
90
|
+
kind === "sent"
|
|
91
|
+
? `${entry.target ?? ""} ${entry.summary ?? ""}`
|
|
92
|
+
: `${entry.chat ?? ""} ${entry.chatId ?? ""} ${entry.from ?? ""}`;
|
|
93
|
+
return haystack.toLowerCase().includes(needle);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const shown = hits.slice(-limit);
|
|
97
|
+
if (shown.length === 0) return kind === "sent" ? "No messages sent in that window." : "No dispatch entries in that window.";
|
|
98
|
+
|
|
99
|
+
const lines = shown.map((entry) => {
|
|
100
|
+
const at = new Date(entry.at).toLocaleString();
|
|
101
|
+
if (kind === "sent") {
|
|
102
|
+
const failed = entry.error ? ` (failed: ${entry.error})` : "";
|
|
103
|
+
return `- ${at} · ${entry.tool} → ${entry.target}: ${entry.summary}${failed}`;
|
|
104
|
+
}
|
|
105
|
+
const parts = [`- ${at} · ${entry.chat} · ${entry.event}`];
|
|
106
|
+
if (entry.from) parts.push(`from ${entry.from}`);
|
|
107
|
+
if (entry.request) parts.push(`asked: ${entry.request}`);
|
|
108
|
+
if (entry.result) parts.push(`answered: ${entry.result}`);
|
|
109
|
+
if (entry.durationMs) parts.push(`${Math.round(entry.durationMs / 1000)} s`);
|
|
110
|
+
if (entry.detail) parts.push(entry.detail);
|
|
111
|
+
return parts.join(" · ");
|
|
112
|
+
});
|
|
113
|
+
const more = hits.length > shown.length ? `\n\n(${hits.length - shown.length} older entries not shown)` : "";
|
|
114
|
+
return lines.join("\n") + more;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const teamsHistoryTool = {
|
|
118
|
+
name: "teams_history",
|
|
119
|
+
description:
|
|
120
|
+
"Look up what pi did in Teams across chats: kind 'sent' lists messages pi sent (from the audit log), kind " +
|
|
121
|
+
"'dispatch' lists requests the per-chat workers picked up and what they answered. Use it when someone asks " +
|
|
122
|
+
"what pi told another person or chat. Read-only.",
|
|
123
|
+
parameters: Type.Object({
|
|
124
|
+
kind: Type.Optional(
|
|
125
|
+
Type.Union([Type.Literal("sent"), Type.Literal("dispatch")], {
|
|
126
|
+
description: "'sent' (default): messages pi sent. 'dispatch': requests and answers of the chat workers.",
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
chat: Type.Optional(Type.String({ description: "Only entries whose chat or person contains this text" })),
|
|
130
|
+
sinceHours: Type.Optional(Type.Number({ description: "How far back, in hours (default 24, max 2160)" })),
|
|
131
|
+
limit: Type.Optional(Type.Number({ description: "Most recent entries to show (default 20, max 200)" })),
|
|
132
|
+
}),
|
|
133
|
+
promptSnippet: "Look up what pi wrote in other Teams chats",
|
|
134
|
+
|
|
135
|
+
async execute(
|
|
136
|
+
_toolCallId: string,
|
|
137
|
+
params: HistoryParams,
|
|
138
|
+
_signal: AbortSignal | undefined,
|
|
139
|
+
_onUpdate: undefined,
|
|
140
|
+
ctx: ToolContext,
|
|
141
|
+
): Promise<ToolResult> {
|
|
142
|
+
return run(async () => {
|
|
143
|
+
const readers = ctx.connection?.watch.dispatch.historyReaders ?? [];
|
|
144
|
+
const refusal = historyRefusal(workerIdentity(), readers);
|
|
145
|
+
if (refusal) return errorResult(refusal);
|
|
146
|
+
|
|
147
|
+
const path = (params.kind ?? "sent") === "sent" ? getAuditPath() : getDispatchJournalPath(getAgentDir());
|
|
148
|
+
const text = renderHistory(readJsonlTail(path), params);
|
|
149
|
+
return textResult(text, { path });
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
};
|
package/src/tools/teams-watch.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type ResolvedMentionOnly,
|
|
17
17
|
type TeamsConnection,
|
|
18
18
|
type WatchConfig,
|
|
19
|
+
type ResolvedDispatchConfig,
|
|
19
20
|
} from "../config/index.ts";
|
|
20
21
|
import { getActiveWatchLoop, type WatchRuntimeStatus } from "../watch/loop.ts";
|
|
21
22
|
import { getWatchCursorPath } from "../watch/cursor.ts";
|
|
@@ -72,6 +73,7 @@ function describe(watch: {
|
|
|
72
73
|
mentionOnly: ResolvedMentionOnly;
|
|
73
74
|
cooldownSeconds: number;
|
|
74
75
|
maxTriggersPerHour: number;
|
|
76
|
+
dispatch?: ResolvedDispatchConfig;
|
|
75
77
|
}): string {
|
|
76
78
|
return [
|
|
77
79
|
`- enabled: ${watch.enabled}`,
|
|
@@ -82,6 +84,9 @@ function describe(watch: {
|
|
|
82
84
|
describeMentionOnly(watch.mentionOnly),
|
|
83
85
|
`- cooldown per chat: ${watch.cooldownSeconds} s`,
|
|
84
86
|
`- wake limit: ${watch.maxTriggersPerHour} per hour`,
|
|
87
|
+
watch.dispatch?.mode === "process"
|
|
88
|
+
? `- dispatch: one pi process per chat, ${watch.dispatch.maxConcurrent} in parallel`
|
|
89
|
+
: "- dispatch: every wake is a turn in this session",
|
|
85
90
|
].join("\n");
|
|
86
91
|
}
|
|
87
92
|
|