@patimweb/pi-teams 1.14.0 → 2.0.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 +43 -0
- package/docs/tools.md +1 -0
- package/package.json +1 -1
- package/src/config/index.ts +10 -0
- package/src/extension/index.ts +95 -7
- package/src/tools/teams-history.ts +128 -0
- package/src/watch/control.ts +45 -0
- package/src/watch/pin.ts +62 -0
- package/src/watch/worker.ts +79 -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
|
+
| `historyReaders` | `[]` | who may use `teams_history` from their one-to-one chat, where each chat has a process of its own. See [below](#parallel-chats-with-pi-lanes). |
|
|
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,48 @@ 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
|
+
A process that answers one chat only (see [below](#parallel-chats-with-pi-lanes))
|
|
162
|
+
is pinned to it for its whole life.
|
|
163
|
+
|
|
164
|
+
## Parallel chats with pi-lanes
|
|
165
|
+
|
|
166
|
+
Every wake is a follow-up in the session that runs the watcher. That session
|
|
167
|
+
works on one chat at a time: while it answers Anna, Bob waits.
|
|
168
|
+
|
|
169
|
+
With [pi-lanes](https://github.com/Smotherer007/pi-lanes) installed, every
|
|
170
|
+
chat gets a pi process of its own, with a session that carries on from message
|
|
171
|
+
to message; different chats are answered in parallel. pi-teams does not do the
|
|
172
|
+
routing itself. Before each wake prompt it emits a hint on pi's event bus
|
|
173
|
+
(`pi-lanes:route`), and a router takes it from there. Without a router nobody
|
|
174
|
+
listens, and everything stays as described above.
|
|
175
|
+
|
|
176
|
+
The hint carries:
|
|
177
|
+
|
|
178
|
+
| Field | Value |
|
|
179
|
+
|-------|-------|
|
|
180
|
+
| `lane` | `teams:<account>:<chat id>`: same chat, same process and session |
|
|
181
|
+
| `env` | `PI_TEAMS_WORKER_CHAT` and friends (below) |
|
|
182
|
+
| `command` | the newest message without mention and punctuation, so "Neo, stopp!" reads as `stopp` |
|
|
183
|
+
| `trusted` | `true` only in a one-to-one chat with a person in `historyReaders` |
|
|
184
|
+
|
|
185
|
+
A process started with `PI_TEAMS_WORKER_CHAT` answers that chat only: it is
|
|
186
|
+
pinned to it for good, never starts a watcher of its own, and `teams_watch`,
|
|
187
|
+
`teams_setup` and `teams_logout` are closed to it. Listen mode also starts on
|
|
188
|
+
its own only in the terminal session (`ctx.mode === "tui"`), never in a pi
|
|
189
|
+
driven over RPC.
|
|
190
|
+
|
|
191
|
+
### What pi said in other chats
|
|
192
|
+
|
|
193
|
+
Separate processes mean that no single context knows what pi told somebody
|
|
194
|
+
else. The audit log does (`audit: true`), and `teams_history` reads it. In a
|
|
195
|
+
process that answers one chat the tool works only in a one-to-one chat with a
|
|
196
|
+
person listed in `historyReaders`; the terminal session is never restricted.
|
|
197
|
+
|
|
155
198
|
## Cost
|
|
156
199
|
|
|
157
200
|
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, from the audit log. 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 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patimweb/pi-teams",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.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,13 @@ 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
|
+
* People who may, from their one-to-one chat with pi, read what pi wrote in
|
|
140
|
+
* other chats (`teams_history`). Matched like `from`. Empty means nobody.
|
|
141
|
+
* Only matters where a pi process answers a single chat, e.g. a lane run by
|
|
142
|
+
* pi-lanes; the session a person types in is never restricted.
|
|
143
|
+
*/
|
|
144
|
+
historyReaders?: string[];
|
|
138
145
|
}
|
|
139
146
|
|
|
140
147
|
/**
|
|
@@ -189,6 +196,7 @@ export interface ResolvedWatchConfig {
|
|
|
189
196
|
mentionOnly: ResolvedMentionOnly;
|
|
190
197
|
cooldownSeconds: number;
|
|
191
198
|
maxTriggersPerHour: number;
|
|
199
|
+
historyReaders: string[];
|
|
192
200
|
}
|
|
193
201
|
|
|
194
202
|
/**
|
|
@@ -384,6 +392,7 @@ export const WATCH_DEFAULTS: ResolvedWatchConfig = {
|
|
|
384
392
|
mentionOnly: { default: false, chats: [], people: [] },
|
|
385
393
|
cooldownSeconds: 300,
|
|
386
394
|
maxTriggersPerHour: 10,
|
|
395
|
+
historyReaders: [],
|
|
387
396
|
};
|
|
388
397
|
|
|
389
398
|
/**
|
|
@@ -802,6 +811,7 @@ export function resolveWatchConfig(
|
|
|
802
811
|
WATCH_BOUNDS.maxTriggersPerHour,
|
|
803
812
|
WATCH_DEFAULTS.maxTriggersPerHour,
|
|
804
813
|
),
|
|
814
|
+
historyReaders: normalizePatterns(pick("historyReaders")),
|
|
805
815
|
};
|
|
806
816
|
}
|
|
807
817
|
|
package/src/extension/index.ts
CHANGED
|
@@ -34,11 +34,16 @@ import {
|
|
|
34
34
|
setActiveWatchLoop,
|
|
35
35
|
startWatchLoop,
|
|
36
36
|
stopActiveWatchLoop,
|
|
37
|
+
type WatchEvent as WakeEvent,
|
|
37
38
|
type WatchLoop,
|
|
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 { commandText } from "../watch/control.ts";
|
|
44
|
+
import { workerEnv, workerIdentity, workerViolation } from "../watch/worker.ts";
|
|
45
|
+
import { matchesPattern } from "../config/scope.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,
|
|
@@ -101,6 +107,48 @@ function mentionSummary(mentionOnly: ResolvedMentionOnly): string {
|
|
|
101
107
|
return overrides > 0 ? `${base} (${overrides} override${overrides === 1 ? "" : "s"})` : base;
|
|
102
108
|
}
|
|
103
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The event-bus channel a lane router listens on (pi-lanes). Emitting on it is
|
|
112
|
+
* free when nobody listens, so pi-teams needs no dependency on the router.
|
|
113
|
+
*/
|
|
114
|
+
const LANE_HINT_CHANNEL = "pi-lanes:route";
|
|
115
|
+
|
|
116
|
+
/** One line, at most `max` characters. */
|
|
117
|
+
function oneLine(text: string | undefined, max = 160): string | undefined {
|
|
118
|
+
const flat = text?.replace(/\s+/g, " ").trim();
|
|
119
|
+
if (!flat) return undefined;
|
|
120
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The routing hint for one wake: which conversation it belongs to, and what
|
|
125
|
+
* the process that answers it must know about itself.
|
|
126
|
+
*
|
|
127
|
+
* - `lane` is the chat, per account: same chat, same lane and session.
|
|
128
|
+
* - `env` pins that process to the chat (../watch/worker.ts).
|
|
129
|
+
* - `command` is the newest message without mention and punctuation, so the
|
|
130
|
+
* router can recognise "stopp" or "neues Thema".
|
|
131
|
+
* - `trusted` is true only in a one-to-one chat with a `historyReaders` entry:
|
|
132
|
+
* the only place where looking at other conversations is allowed.
|
|
133
|
+
*/
|
|
134
|
+
export function laneHint(conn: TeamsConnection, event: WakeEvent, prompt: string) {
|
|
135
|
+
const message = withSenderAddresses(event.message, event.chat);
|
|
136
|
+
const oneOnOne = event.chat.chatType === "oneOnOne";
|
|
137
|
+
const address = message.from?.mail ?? message.from?.upn;
|
|
138
|
+
const peer = oneOnOne ? address : undefined;
|
|
139
|
+
const trusted = !!peer && conn.watch.historyReaders.some((pattern) => matchesPattern(pattern, peer));
|
|
140
|
+
return {
|
|
141
|
+
text: prompt,
|
|
142
|
+
lane: `teams:${conn.account}:${event.chat.id}`,
|
|
143
|
+
label: `Teams: ${event.chat.label}`,
|
|
144
|
+
env: workerEnv({ chatId: event.chat.id, label: event.chat.label, chatType: event.chat.chatType, peer }),
|
|
145
|
+
command: commandText(message, event.me?.displayName),
|
|
146
|
+
request: oneLine(message.text),
|
|
147
|
+
from: message.from?.displayName,
|
|
148
|
+
trusted,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
104
152
|
/** Every tool, in the order they appear in the documentation. */
|
|
105
153
|
const tools = [
|
|
106
154
|
// Setup & session
|
|
@@ -137,6 +185,7 @@ const tools = [
|
|
|
137
185
|
teamsUpdateMessageTool,
|
|
138
186
|
teamsDeleteMessageTool,
|
|
139
187
|
teamsInboxTool,
|
|
188
|
+
teamsHistoryTool,
|
|
140
189
|
// Presence
|
|
141
190
|
teamsGetPresenceTool,
|
|
142
191
|
teamsSetPresenceTool,
|
|
@@ -178,6 +227,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
178
227
|
*/
|
|
179
228
|
let watchSignature: string | undefined;
|
|
180
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Set when this process answers one chat only (a lane started for it, see
|
|
232
|
+
* ../watch/worker.ts): it never watches, and may write nowhere else.
|
|
233
|
+
*/
|
|
234
|
+
const worker = workerIdentity();
|
|
235
|
+
|
|
181
236
|
/** The last polling error the user was told about, so it is said once. */
|
|
182
237
|
let reportedWatchError: string | undefined;
|
|
183
238
|
|
|
@@ -222,10 +277,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
222
277
|
cursor: readWatchCursor(conn.account, conn.tenantId),
|
|
223
278
|
persistCursor: (seen) => writeWatchCursor(conn.account, conn.tenantId, seen),
|
|
224
279
|
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
280
|
// Mark the chat read the moment pi picks it up: with read receipts on,
|
|
230
281
|
// the sender sees the "seen" eye right away — the closest thing to a
|
|
231
282
|
// typing indicator Graph offers (there is no typing API for users).
|
|
@@ -233,7 +284,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
233
284
|
if (event.me?.id) {
|
|
234
285
|
void setChatReadState(conn, event.chat.id, event.me, true).catch(() => undefined);
|
|
235
286
|
}
|
|
236
|
-
|
|
287
|
+
const prompt = composeWatchPrompt(event, event.me);
|
|
288
|
+
|
|
289
|
+
// Tell a router which conversation this prompt belongs to. With
|
|
290
|
+
// pi-lanes installed, every chat is answered by a pi process of its
|
|
291
|
+
// own, in parallel with the others; without it nobody listens and the
|
|
292
|
+
// prompt takes the usual way into this session.
|
|
293
|
+
pi.events.emit(LANE_HINT_CHANNEL, laneHint(conn, event, prompt));
|
|
294
|
+
|
|
295
|
+
// The turn this prompt starts may only answer the chat that woke pi.
|
|
296
|
+
// The pin is taken when the prompt enters the transcript, not now:
|
|
297
|
+
// while pi is busy the prompt waits in the queue, and pinning here
|
|
298
|
+
// would take the pin away from the answer still being written.
|
|
299
|
+
queueWakePin(prompt, event.chat.id, event.chat.label);
|
|
300
|
+
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
237
301
|
},
|
|
238
302
|
onTick: (status) => {
|
|
239
303
|
if (!status.lastError) reportedWatchError = undefined;
|
|
@@ -456,7 +520,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
456
520
|
// and the same config: autoStart there would run a second watcher that
|
|
457
521
|
// answers chats inside a throwaway process and advances the shared cursor,
|
|
458
522
|
// so the real session never sees those messages.
|
|
459
|
-
|
|
523
|
+
// A pi driven over RPC (a lane, an IDE) has a UI too, but nobody is
|
|
524
|
+
// typing in it: only the terminal session listens on its own.
|
|
525
|
+
const interactive = (ctx.mode ?? (ctx.hasUI ? "tui" : "print")) === "tui";
|
|
526
|
+
if (connection.watch.autoStart && interactive && !worker) startWatch(ctx, { explicit: true });
|
|
460
527
|
|
|
461
528
|
paintStatus(ctx);
|
|
462
529
|
|
|
@@ -517,6 +584,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
517
584
|
stopWatch();
|
|
518
585
|
});
|
|
519
586
|
|
|
587
|
+
// A queued wake prompt has entered the transcript: its turn starts now, so
|
|
588
|
+
// this is when the answer gets pinned to its chat.
|
|
589
|
+
pi.on("message_start", async (event: any) => {
|
|
590
|
+
const message = event?.message;
|
|
591
|
+
if (message?.role !== "user") return;
|
|
592
|
+
const content = message.content;
|
|
593
|
+
const text =
|
|
594
|
+
typeof content === "string"
|
|
595
|
+
? content
|
|
596
|
+
: Array.isArray(content)
|
|
597
|
+
? content
|
|
598
|
+
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
|
599
|
+
.map((part: any) => part.text)
|
|
600
|
+
.join("")
|
|
601
|
+
: "";
|
|
602
|
+
if (text) claimWakePin(text);
|
|
603
|
+
});
|
|
604
|
+
|
|
520
605
|
// -----------------------------------------------------------------------
|
|
521
606
|
// Safety interceptor
|
|
522
607
|
// -----------------------------------------------------------------------
|
|
@@ -528,6 +613,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
528
613
|
});
|
|
529
614
|
|
|
530
615
|
pi.on("tool_call", async (event, ctx) => {
|
|
616
|
+
const workerBlock = workerViolation(event.toolName, worker);
|
|
617
|
+
if (workerBlock) return { block: true, reason: workerBlock };
|
|
618
|
+
|
|
531
619
|
if (!isMutationTool(event.toolName)) return;
|
|
532
620
|
|
|
533
621
|
// A tool may target a different account than the session default, so the
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* teams_history — what pi wrote in Teams, across chats.
|
|
3
|
+
*
|
|
4
|
+
* When every chat is answered by a pi process of its own (a lane, e.g. with
|
|
5
|
+
* pi-lanes), no single context knows what pi said elsewhere. The audit log
|
|
6
|
+
* does (every message pi sent, see ../safety/audit.ts), and this tool reads it
|
|
7
|
+
* to answer "what did you tell Anna today?".
|
|
8
|
+
*
|
|
9
|
+
* Reading across chats is exactly what separate processes prevent by default,
|
|
10
|
+
* so in a process that answers one chat the tool works only in a one-to-one
|
|
11
|
+
* chat with somebody listed in `watch.historyReaders`. The session a person
|
|
12
|
+
* types in is never restricted.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, openSync, readSync, closeSync, statSync } from "node:fs";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
import { matchesPattern } from "../config/scope.ts";
|
|
18
|
+
import { getAuditPath } from "../safety/audit.ts";
|
|
19
|
+
import { workerIdentity, type WorkerIdentity } from "../watch/worker.ts";
|
|
20
|
+
import { errorResult, run, textResult, type ToolContext, type ToolResult } from "./shared.ts";
|
|
21
|
+
|
|
22
|
+
/** How much of the end of a log is read. Old entries are the least asked for. */
|
|
23
|
+
const TAIL_BYTES = 2 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
/** The last `TAIL_BYTES` of a JSONL file, parsed. Broken lines are skipped. */
|
|
26
|
+
export function readJsonlTail(path: string, maxBytes = TAIL_BYTES): Record<string, any>[] {
|
|
27
|
+
if (!existsSync(path)) return [];
|
|
28
|
+
const size = statSync(path).size;
|
|
29
|
+
const start = Math.max(0, size - maxBytes);
|
|
30
|
+
const buffer = Buffer.alloc(size - start);
|
|
31
|
+
const fd = openSync(path, "r");
|
|
32
|
+
try {
|
|
33
|
+
readSync(fd, buffer, 0, buffer.length, start);
|
|
34
|
+
} finally {
|
|
35
|
+
closeSync(fd);
|
|
36
|
+
}
|
|
37
|
+
const lines = buffer.toString("utf-8").split("\n");
|
|
38
|
+
if (start > 0) lines.shift(); // first line is cut
|
|
39
|
+
const entries: Record<string, any>[] = [];
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
if (!line.trim()) continue;
|
|
42
|
+
try {
|
|
43
|
+
entries.push(JSON.parse(line));
|
|
44
|
+
} catch {
|
|
45
|
+
/* skip */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return entries;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Why this process may not read across chats, or undefined when it may.
|
|
53
|
+
* Exported for tests.
|
|
54
|
+
*/
|
|
55
|
+
export function historyRefusal(identity: WorkerIdentity | undefined, readers: string[]): string | undefined {
|
|
56
|
+
if (!identity) return undefined;
|
|
57
|
+
const allowed =
|
|
58
|
+
identity.chatType === "oneOnOne" &&
|
|
59
|
+
!!identity.peer &&
|
|
60
|
+
readers.some((pattern) => matchesPattern(pattern, identity.peer!));
|
|
61
|
+
if (allowed) return undefined;
|
|
62
|
+
return (
|
|
63
|
+
"What pi wrote in other chats is not available in this chat. Each chat keeps its own context, and only " +
|
|
64
|
+
"the people listed in watch.historyReaders may look across chats, from their one-to-one chat with pi."
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface HistoryParams {
|
|
69
|
+
chat?: string;
|
|
70
|
+
sinceHours?: number;
|
|
71
|
+
limit?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Filter and render log entries. Exported for tests. */
|
|
75
|
+
export function renderHistory(entries: Record<string, any>[], params: HistoryParams, now = Date.now()): string {
|
|
76
|
+
const limit = Math.min(Math.max(params.limit ?? 20, 1), 200);
|
|
77
|
+
const since = now - Math.min(Math.max(params.sinceHours ?? 24, 1), 24 * 90) * 3_600_000;
|
|
78
|
+
const needle = params.chat?.trim().toLowerCase();
|
|
79
|
+
|
|
80
|
+
const hits = entries.filter((entry) => {
|
|
81
|
+
const at = Date.parse(entry.at ?? "");
|
|
82
|
+
if (!Number.isFinite(at) || at < since) return false;
|
|
83
|
+
if (!needle) return true;
|
|
84
|
+
return `${entry.target ?? ""} ${entry.summary ?? ""}`.toLowerCase().includes(needle);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const shown = hits.slice(-limit);
|
|
88
|
+
if (shown.length === 0) return "No messages sent in that window.";
|
|
89
|
+
|
|
90
|
+
const lines = shown.map((entry) => {
|
|
91
|
+
const at = new Date(entry.at).toLocaleString();
|
|
92
|
+
const failed = entry.error ? ` (failed: ${entry.error})` : "";
|
|
93
|
+
return `- ${at} · ${entry.tool} → ${entry.target}: ${entry.summary}${failed}`;
|
|
94
|
+
});
|
|
95
|
+
const more = hits.length > shown.length ? `\n\n(${hits.length - shown.length} older entries not shown)` : "";
|
|
96
|
+
return lines.join("\n") + more;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const teamsHistoryTool = {
|
|
100
|
+
name: "teams_history",
|
|
101
|
+
description:
|
|
102
|
+
"Look up what pi sent in Teams across chats (from the audit log). Use it when someone asks what pi told " +
|
|
103
|
+
"another person or chat. Read-only.",
|
|
104
|
+
parameters: Type.Object({
|
|
105
|
+
chat: Type.Optional(Type.String({ description: "Only entries whose chat or person contains this text" })),
|
|
106
|
+
sinceHours: Type.Optional(Type.Number({ description: "How far back, in hours (default 24, max 2160)" })),
|
|
107
|
+
limit: Type.Optional(Type.Number({ description: "Most recent entries to show (default 20, max 200)" })),
|
|
108
|
+
}),
|
|
109
|
+
promptSnippet: "Look up what pi wrote in other Teams chats",
|
|
110
|
+
|
|
111
|
+
async execute(
|
|
112
|
+
_toolCallId: string,
|
|
113
|
+
params: HistoryParams,
|
|
114
|
+
_signal: AbortSignal | undefined,
|
|
115
|
+
_onUpdate: undefined,
|
|
116
|
+
ctx: ToolContext,
|
|
117
|
+
): Promise<ToolResult> {
|
|
118
|
+
return run(async () => {
|
|
119
|
+
const readers = ctx.connection?.watch.historyReaders ?? [];
|
|
120
|
+
const refusal = historyRefusal(workerIdentity(), readers);
|
|
121
|
+
if (refusal) return errorResult(refusal);
|
|
122
|
+
|
|
123
|
+
const path = getAuditPath();
|
|
124
|
+
const text = renderHistory(readJsonlTail(path), params);
|
|
125
|
+
return textResult(text, { path });
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Listen mode — the text of a trigger, as a command.
|
|
3
|
+
*
|
|
4
|
+
* A router such as pi-lanes can treat a short message as a control word
|
|
5
|
+
* ("stopp", "neues Thema") instead of a request. It needs the message the way
|
|
6
|
+
* the person meant it: without the mention of pi in front, without
|
|
7
|
+
* punctuation. This module produces exactly that, and nothing more; which
|
|
8
|
+
* words mean what is the router's business.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { MessageSummary } from "../types.ts";
|
|
12
|
+
|
|
13
|
+
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14
|
+
|
|
15
|
+
/** Lower-case the text and drop mentions, punctuation and extra spaces. */
|
|
16
|
+
export function normalizeCommand(text: string, names: string[] = []): string {
|
|
17
|
+
let result = ` ${text.toLowerCase()} `;
|
|
18
|
+
const sorted = [...new Set(names.map((n) => n.trim().toLowerCase()).filter(Boolean))].sort((a, b) => b.length - a.length);
|
|
19
|
+
for (const name of sorted) {
|
|
20
|
+
result = result.replace(new RegExp(`(^|[\\s@])${escapeRegExp(name)}(?=[\\s,.:;!?]|$)`, "g"), " ");
|
|
21
|
+
}
|
|
22
|
+
return result
|
|
23
|
+
.replace(/@/g, " ")
|
|
24
|
+
.replace(/[.,;:!?"'„“”«»()[\]]/g, " ")
|
|
25
|
+
.replace(/\s+/g, " ")
|
|
26
|
+
.trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The names pi can be addressed by, for stripping a leading mention.
|
|
31
|
+
*
|
|
32
|
+
* "Neo (neoimpulse)" is mentioned as "Neo" in Teams, so the part before the
|
|
33
|
+
* first space or bracket counts as well.
|
|
34
|
+
*/
|
|
35
|
+
export function selfNames(displayName: string | undefined): string[] {
|
|
36
|
+
if (!displayName) return [];
|
|
37
|
+
const short = displayName.split(/[\s(]/)[0] ?? "";
|
|
38
|
+
return [displayName, short].filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The newest message as a command: no mentions, no punctuation, lower case. */
|
|
42
|
+
export function commandText(message: Pick<MessageSummary, "text" | "mentions">, myDisplayName?: string): string {
|
|
43
|
+
const names = [...(message.mentions ?? []).map((m) => m.displayName), ...selfNames(myDisplayName)];
|
|
44
|
+
return normalizeCommand(message.text ?? "", names);
|
|
45
|
+
}
|
package/src/watch/pin.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* turn, and one session runs one watcher.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { workerIdentity, type WorkerIdentity } from "./worker.ts";
|
|
21
|
+
|
|
20
22
|
/**
|
|
21
23
|
* How long a pin can survive without the turn ending.
|
|
22
24
|
*
|
|
@@ -35,6 +37,57 @@ interface Pin {
|
|
|
35
37
|
|
|
36
38
|
let pin: Pin | undefined;
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Wakes whose prompt is queued but whose turn has not started yet.
|
|
42
|
+
*
|
|
43
|
+
* A wake is delivered as a follow-up: when pi is busy it waits in the queue.
|
|
44
|
+
* Pinning at wake time would therefore move the pin away from the answer that
|
|
45
|
+
* is still being written — the reply to chat A would be refused because chat B
|
|
46
|
+
* had just arrived. The pin is taken instead when the queued prompt actually
|
|
47
|
+
* enters the transcript (see `claimWakePin`), keyed by its exact text.
|
|
48
|
+
*/
|
|
49
|
+
const pending = new Map<string, Pin>();
|
|
50
|
+
|
|
51
|
+
/** The worker identity of this process, read once. Overridable for tests. */
|
|
52
|
+
let worker: WorkerIdentity | undefined = workerIdentity();
|
|
53
|
+
|
|
54
|
+
/** Tests only: pretend this process is (or is not) a chat worker. */
|
|
55
|
+
export function setWorkerIdentityForTests(identity: WorkerIdentity | undefined): void {
|
|
56
|
+
worker = identity;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Remember a queued wake, so its pin can be taken when its turn starts.
|
|
61
|
+
*
|
|
62
|
+
* Entries older than the pin TTL are dropped on the way: a prompt that never
|
|
63
|
+
* made it into a turn (cancelled queue, restart) must not pin a later one.
|
|
64
|
+
*/
|
|
65
|
+
export function queueWakePin(prompt: string, chatId: string, label: string, now = Date.now()): void {
|
|
66
|
+
for (const [text, entry] of pending) {
|
|
67
|
+
if (now - entry.at > PIN_TTL_MS) pending.delete(text);
|
|
68
|
+
}
|
|
69
|
+
pending.set(prompt, { chatId, label, at: now });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A user message entered the transcript: if it is a queued wake, pin it now.
|
|
74
|
+
*
|
|
75
|
+
* Returns whether a pin was taken. Anything else — typed input, other
|
|
76
|
+
* extensions' prompts — leaves the pin alone.
|
|
77
|
+
*/
|
|
78
|
+
export function claimWakePin(text: string, now = Date.now()): boolean {
|
|
79
|
+
const entry = pending.get(text);
|
|
80
|
+
if (!entry) return false;
|
|
81
|
+
pending.delete(text);
|
|
82
|
+
pin = { chatId: entry.chatId, label: entry.label, at: now };
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** How many wakes are queued but not yet pinned. */
|
|
87
|
+
export function pendingWakePins(): number {
|
|
88
|
+
return pending.size;
|
|
89
|
+
}
|
|
90
|
+
|
|
38
91
|
/** Pin the answer of the turn that is about to start to one chat. */
|
|
39
92
|
export function pinWakeTarget(chatId: string, label: string, now = Date.now()): void {
|
|
40
93
|
pin = { chatId, label, at: now };
|
|
@@ -45,8 +98,17 @@ export function clearWakeTarget(): void {
|
|
|
45
98
|
pin = undefined;
|
|
46
99
|
}
|
|
47
100
|
|
|
101
|
+
/** Tests only: forget every queued wake as well. */
|
|
102
|
+
export function resetWakePinsForTests(): void {
|
|
103
|
+
pin = undefined;
|
|
104
|
+
pending.clear();
|
|
105
|
+
}
|
|
106
|
+
|
|
48
107
|
/** The chat an answer is currently owed to, if any. */
|
|
49
108
|
export function getWakeTarget(now = Date.now()): { chatId: string; label: string } | undefined {
|
|
109
|
+
// A chat worker is pinned for its whole life: it exists to answer one chat,
|
|
110
|
+
// so there is no turn after which it may write anywhere else.
|
|
111
|
+
if (worker) return { chatId: worker.chatId, label: worker.label };
|
|
50
112
|
if (!pin) return undefined;
|
|
51
113
|
if (now - pin.at > PIN_TTL_MS) {
|
|
52
114
|
pin = undefined;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Listen mode — who a chat worker is.
|
|
3
|
+
*
|
|
4
|
+
* Where a chat is answered by a pi process of its own (a lane, e.g. with
|
|
5
|
+
* pi-lanes), that process loads this extension like any other, so it has to
|
|
6
|
+
* know two things the moment it starts: that it must never run a watcher of
|
|
7
|
+
* its own, and which single chat it may write to.
|
|
8
|
+
*
|
|
9
|
+
* Both travel in the environment, set by the router that starts the lane (see
|
|
10
|
+
* `laneHint` in ../extension/index.ts). An environment variable is the one
|
|
11
|
+
* channel that exists before the first line of the extension runs, and the
|
|
12
|
+
* model cannot change it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Environment variables a chat worker is started with. */
|
|
16
|
+
export const WORKER_ENV = {
|
|
17
|
+
/** The chat this worker answers, and the only one it may write to */
|
|
18
|
+
chat: "PI_TEAMS_WORKER_CHAT",
|
|
19
|
+
/** Chat label, for refusal messages and the session name */
|
|
20
|
+
label: "PI_TEAMS_WORKER_LABEL",
|
|
21
|
+
/** "oneOnOne" | "group" | "meeting" */
|
|
22
|
+
chatType: "PI_TEAMS_WORKER_CHAT_TYPE",
|
|
23
|
+
/**
|
|
24
|
+
* Address of the other person, set for one-to-one chats only.
|
|
25
|
+
*
|
|
26
|
+
* Used to decide whether this chat may look at what pi wrote elsewhere
|
|
27
|
+
* (`teams_history`). In a group chat there is no single person to ask, so
|
|
28
|
+
* the variable is absent and the answer is no.
|
|
29
|
+
*/
|
|
30
|
+
peer: "PI_TEAMS_WORKER_PEER",
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
export interface WorkerIdentity {
|
|
34
|
+
chatId: string;
|
|
35
|
+
label: string;
|
|
36
|
+
chatType?: string;
|
|
37
|
+
peer?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The chat this process works for, or undefined in an ordinary pi session. */
|
|
41
|
+
export function workerIdentity(env: NodeJS.ProcessEnv = process.env): WorkerIdentity | undefined {
|
|
42
|
+
const chatId = env[WORKER_ENV.chat]?.trim();
|
|
43
|
+
if (!chatId) return undefined;
|
|
44
|
+
return {
|
|
45
|
+
chatId,
|
|
46
|
+
label: env[WORKER_ENV.label]?.trim() || chatId,
|
|
47
|
+
chatType: env[WORKER_ENV.chatType]?.trim() || undefined,
|
|
48
|
+
peer: env[WORKER_ENV.peer]?.trim() || undefined,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The environment a worker for this chat is started with. */
|
|
53
|
+
export function workerEnv(identity: WorkerIdentity): Record<string, string> {
|
|
54
|
+
const env: Record<string, string> = {
|
|
55
|
+
[WORKER_ENV.chat]: identity.chatId,
|
|
56
|
+
[WORKER_ENV.label]: identity.label,
|
|
57
|
+
};
|
|
58
|
+
if (identity.chatType) env[WORKER_ENV.chatType] = identity.chatType;
|
|
59
|
+
if (identity.peer) env[WORKER_ENV.peer] = identity.peer;
|
|
60
|
+
return env;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Tools a chat worker may not call at all.
|
|
65
|
+
*
|
|
66
|
+
* A worker answers one conversation. Switching listen mode, rewriting the
|
|
67
|
+
* config or signing the account out would change what every other worker and
|
|
68
|
+
* the watcher itself do, which is not a decision one chat gets to make.
|
|
69
|
+
*/
|
|
70
|
+
export const WORKER_FORBIDDEN_TOOLS = new Set<string>(["teams_watch", "teams_setup", "teams_logout"]);
|
|
71
|
+
|
|
72
|
+
export function workerViolation(toolName: string, identity: WorkerIdentity | undefined): string | undefined {
|
|
73
|
+
if (!identity) return undefined;
|
|
74
|
+
if (!WORKER_FORBIDDEN_TOOLS.has(toolName)) return undefined;
|
|
75
|
+
return (
|
|
76
|
+
`This pi process answers the Teams chat "${identity.label}" only. ${toolName} changes listen mode or the ` +
|
|
77
|
+
"account for every chat, so it is not available here. Ask the user to do it in the main session."
|
|
78
|
+
);
|
|
79
|
+
}
|