@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/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,78 @@
1
+ /**
2
+ * Listen mode — who a chat worker is.
3
+ *
4
+ * In dispatch mode (`watch.dispatch.mode: "process"`) every chat that wakes pi
5
+ * gets a pi process of its own. That process loads this extension like any
6
+ * other, so it has to know two things the moment it starts: that it must never
7
+ * run a watcher of its own, and which single chat it may write to.
8
+ *
9
+ * Both travel in the environment, set by the dispatcher when it spawns the
10
+ * worker. An environment variable is the one channel that exists before the
11
+ * first line of the extension runs, and the model cannot change it.
12
+ */
13
+
14
+ /** Environment variables a chat worker is started with. */
15
+ export const WORKER_ENV = {
16
+ /** The chat this worker answers, and the only one it may write to */
17
+ chat: "PI_TEAMS_WORKER_CHAT",
18
+ /** Chat label, for refusal messages and the session name */
19
+ label: "PI_TEAMS_WORKER_LABEL",
20
+ /** "oneOnOne" | "group" | "meeting" */
21
+ chatType: "PI_TEAMS_WORKER_CHAT_TYPE",
22
+ /**
23
+ * Address of the other person, set for one-to-one chats only.
24
+ *
25
+ * Used to decide whether this chat may look at what pi wrote elsewhere
26
+ * (`teams_history`). In a group chat there is no single person to ask, so
27
+ * the variable is absent and the answer is no.
28
+ */
29
+ peer: "PI_TEAMS_WORKER_PEER",
30
+ } as const;
31
+
32
+ export interface WorkerIdentity {
33
+ chatId: string;
34
+ label: string;
35
+ chatType?: string;
36
+ peer?: string;
37
+ }
38
+
39
+ /** The chat this process works for, or undefined in an ordinary pi session. */
40
+ export function workerIdentity(env: NodeJS.ProcessEnv = process.env): WorkerIdentity | undefined {
41
+ const chatId = env[WORKER_ENV.chat]?.trim();
42
+ if (!chatId) return undefined;
43
+ return {
44
+ chatId,
45
+ label: env[WORKER_ENV.label]?.trim() || chatId,
46
+ chatType: env[WORKER_ENV.chatType]?.trim() || undefined,
47
+ peer: env[WORKER_ENV.peer]?.trim() || undefined,
48
+ };
49
+ }
50
+
51
+ /** The environment a worker for this chat is started with. */
52
+ export function workerEnv(identity: WorkerIdentity): Record<string, string> {
53
+ const env: Record<string, string> = {
54
+ [WORKER_ENV.chat]: identity.chatId,
55
+ [WORKER_ENV.label]: identity.label,
56
+ };
57
+ if (identity.chatType) env[WORKER_ENV.chatType] = identity.chatType;
58
+ if (identity.peer) env[WORKER_ENV.peer] = identity.peer;
59
+ return env;
60
+ }
61
+
62
+ /**
63
+ * Tools a chat worker may not call at all.
64
+ *
65
+ * A worker answers one conversation. Switching listen mode, rewriting the
66
+ * config or signing the account out would change what every other worker and
67
+ * the watcher itself do, which is not a decision one chat gets to make.
68
+ */
69
+ export const WORKER_FORBIDDEN_TOOLS = new Set<string>(["teams_watch", "teams_setup", "teams_logout"]);
70
+
71
+ export function workerViolation(toolName: string, identity: WorkerIdentity | undefined): string | undefined {
72
+ if (!identity) return undefined;
73
+ if (!WORKER_FORBIDDEN_TOOLS.has(toolName)) return undefined;
74
+ return (
75
+ `This pi process answers the Teams chat "${identity.label}" only. ${toolName} changes listen mode or the ` +
76
+ "account for every chat, so it is not available here. Ask the user to do it in the main session."
77
+ );
78
+ }