@patimweb/pi-teams 1.3.1 → 1.4.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.
@@ -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 { applyAiFooter } from "../utils/disclosure.ts";
17
18
  import { requireChat } from "./resolve.ts";
18
19
  import {
19
20
  AccountParam,
@@ -65,8 +66,12 @@ export const teamsUpdateMessageTool = {
65
66
  const me = await currentUser(conn, signal);
66
67
  const chat = await requireChat(conn, params.chat, "write", signal);
67
68
 
69
+ // An edit replaces the whole body, so the disclosure has to survive it —
70
+ // idempotently, so a body that already ends with it is not doubled.
71
+ const body = applyAiFooter(params.body, conn.aiFooter, { html: params.html });
72
+
68
73
  await updateMessage(conn, chat.id, params.messageId, me.id, {
69
- body: params.body,
74
+ body,
70
75
  html: params.html,
71
76
  }, { signal });
72
77
 
@@ -76,7 +81,7 @@ export const teamsUpdateMessageTool = {
76
81
  tenant: conn.tenant,
77
82
  actor: me.upn,
78
83
  target: `chat:${chat.label}`,
79
- summary: `edited message ${params.messageId}: ${params.body.slice(0, 120)}`,
84
+ summary: `edited message ${params.messageId}: ${body.slice(0, 120)}`,
80
85
  });
81
86
 
82
87
  return textResult(`✅ Message ${params.messageId} edited in "${chat.label}".`, {
@@ -12,9 +12,11 @@ import {
12
12
  getConfigPath,
13
13
  resolveConnection,
14
14
  setWatchConfig,
15
+ type TeamsConnection,
15
16
  type WatchConfig,
16
17
  } from "../config/index.ts";
17
18
  import { getActiveWatchLoop, type WatchRuntimeStatus } from "../watch/loop.ts";
19
+ import { getWatchCursorPath } from "../watch/cursor.ts";
18
20
  import { errorResult, run, textResult, type ToolContext, type ToolResult } from "./shared.ts";
19
21
 
20
22
  interface WatchParams {
@@ -29,6 +31,24 @@ interface WatchParams {
29
31
  maxTriggersPerHour?: number;
30
32
  }
31
33
 
34
+ /**
35
+ * What the cursor means, in the status output.
36
+ *
37
+ * The single most surprising thing about listen mode is what happens when it is
38
+ * switched on again, so the answer is part of the status rather than only in
39
+ * the docs.
40
+ */
41
+ const CURSOR_NOTES = [
42
+ "### What wakes pi",
43
+ "",
44
+ "A chat wakes pi when both are true: it has moved since pi last looked (a cursor per account, kept "
45
+ + "on disk), and its newest message is still unread for you in Teams. Switching listen mode back "
46
+ + "on therefore answers what you missed, and a chat you have already read in Teams stays quiet.",
47
+ "",
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.",
50
+ ].join("\n");
51
+
32
52
  /** Renders the effective settings the same way the config file would. */
33
53
  function describe(watch: {
34
54
  enabled: boolean;
@@ -56,7 +76,7 @@ function describe(watch: {
56
76
  * Distinguishes "configured on" from "actually polling": after an edit to the
57
77
  * config file the two can differ until the extension picks it up.
58
78
  */
59
- function runtimeLines(runtime: WatchRuntimeStatus | undefined): string[] {
79
+ function runtimeLines(runtime: WatchRuntimeStatus | undefined, conn: TeamsConnection): string[] {
60
80
  if (!runtime) {
61
81
  return [
62
82
  "Nothing is polling in this session.",
@@ -79,9 +99,15 @@ function runtimeLines(runtime: WatchRuntimeStatus | undefined): string[] {
79
99
  if (runtime.lastWakeChat) {
80
100
  lines.push(`- last wake: ${runtime.lastWakeChat}`);
81
101
  }
102
+ if (runtime.catchUp !== undefined) {
103
+ const read = runtime.catchUpRead ? `, ${runtime.catchUpRead} of them already read` : "";
104
+ lines.push(`- catch-up on start: ${runtime.catchUp} chat(s) had moved${read}`);
105
+ }
82
106
  if (runtime.lastError) {
83
107
  lines.push(`- ⚠️ last error: ${runtime.lastError}`);
84
108
  }
109
+
110
+ lines.push(`- cursor: \`${getWatchCursorPath(conn.account, conn.tenantId)}\``);
85
111
  return lines;
86
112
  }
87
113
 
@@ -89,6 +115,9 @@ export const teamsWatchTool = {
89
115
  name: "teams_watch",
90
116
  description:
91
117
  "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. " +
92
121
  "Use action 'status' to see the current settings, 'enable' or 'disable' to switch it, and the optional " +
93
122
  "fields to narrow what may wake pi (chat patterns, mentions only, interval, cooldown, hourly limit). " +
94
123
  "The watcher runs in the extension session; this tool changes the configuration it reads.",
@@ -135,6 +164,7 @@ export const teamsWatchTool = {
135
164
  "Use teams_watch when the user wants pi to notice incoming Teams messages on its own.",
136
165
  "Tell the user plainly that listen mode spends a model turn per incoming message, and that pi answers in their name.",
137
166
  "Enabling listen mode takes effect immediately; the settings survive a restart.",
167
+ "Enabling listen mode answers what arrived since the last run, so say how much is waiting rather than implying only new messages count.",
138
168
  ],
139
169
 
140
170
  async execute(
@@ -149,9 +179,9 @@ export const teamsWatchTool = {
149
179
 
150
180
  // Reported from the resolved connection, so the answer shows what is in
151
181
  // force for that account and tenant — not just what the file says.
152
- const inspect = () => {
182
+ const inspect = (): TeamsConnection | undefined => {
153
183
  try {
154
- return resolveConnection(params.account, params.tenant).watch;
184
+ return resolveConnection(params.account, params.tenant);
155
185
  } catch {
156
186
  return undefined;
157
187
  }
@@ -169,12 +199,13 @@ export const teamsWatchTool = {
169
199
 
170
200
  switch (action) {
171
201
  case "status": {
172
- const watch = inspect();
173
- if (!watch) {
202
+ const conn = inspect();
203
+ if (!conn) {
174
204
  return errorResult(
175
205
  `No usable Teams account for these settings. Check ${getConfigPath()}.`,
176
206
  );
177
207
  }
208
+ const watch = conn.watch;
178
209
  const loop = getActiveWatchLoop();
179
210
  const runtime = loop?.status();
180
211
  return textResult(
@@ -183,7 +214,9 @@ export const teamsWatchTool = {
183
214
  "",
184
215
  describe(watch),
185
216
  "",
186
- ...runtimeLines(runtime),
217
+ ...runtimeLines(runtime, conn),
218
+ "",
219
+ ...CURSOR_NOTES,
187
220
  ].join("\n"),
188
221
  { watch, runtime },
189
222
  );
@@ -197,9 +230,10 @@ export const teamsWatchTool = {
197
230
  "",
198
231
  describe(watch),
199
232
  "",
200
- "pi now polls these chats and treats a new message as a prompt. It answers in your name " +
201
- "and under the safety level in force, so anything it decides to send still follows the " +
202
- "usual confirmation rules.",
233
+ "pi now polls these chats and treats anything that has moved since its last look as a prompt — " +
234
+ "including what arrived while it was not running, so the first tick may answer several messages. " +
235
+ "It answers in your name and under the safety level in force, so anything it decides to send " +
236
+ "still follows the usual confirmation rules.",
203
237
  "",
204
238
  `Written to ${getConfigPath()}.`,
205
239
  ].join("\n"),
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The AI disclosure footer.
3
+ *
4
+ * When `aiFooter` is on, every message pi sends in the user's name ends with a
5
+ * note saying so. It is applied here rather than asked for in a prompt because
6
+ * a disclosure that depends on the model remembering is not a disclosure: this
7
+ * is the one piece of an outgoing message that must not be negotiable.
8
+ *
9
+ * Two properties matter more than the wording:
10
+ *
11
+ * - **Idempotent.** The same body run through this twice carries one footer,
12
+ * so an edit of an already-footered message does not stack them — and a
13
+ * model that copied the footer from the chat it is answering does not
14
+ * produce two.
15
+ * - **Body-shaped.** The body may be markdown or raw HTML depending on the
16
+ * tool call, and the footer has to arrive as text in both. HTML output
17
+ * escapes it and wraps it in its own paragraph.
18
+ */
19
+
20
+ import type { ResolvedAiFooter } from "../config/index.ts";
21
+
22
+ /**
23
+ * Append the disclosure, or return the body untouched.
24
+ *
25
+ * A disabled footer, an empty body, and a body that already ends with the note
26
+ * all come back unchanged — the caller never has to ask whether it should.
27
+ */
28
+ export function applyAiFooter(
29
+ body: string,
30
+ footer: ResolvedAiFooter | undefined,
31
+ options: { html?: boolean } = {},
32
+ ): string {
33
+ if (!footer?.enabled) return body;
34
+
35
+ const text = footer.text.trim();
36
+ // An empty message is refused before it is sent, and a disclosure on its own
37
+ // is not a message.
38
+ if (!text || !body.trim()) return body;
39
+ if (hasAiFooter(body, text)) return body;
40
+
41
+ const trimmed = body.trimEnd();
42
+ return options.html
43
+ ? `${trimmed}\n<p>${escapeHtml(text)}</p>`
44
+ : `${trimmed}\n\n${text}`;
45
+ }
46
+
47
+ /** Does this body already carry the disclosure? */
48
+ export function hasAiFooter(body: string, text: string): boolean {
49
+ const trimmed = body.trimEnd();
50
+ return trimmed.endsWith(text) || trimmed.endsWith(`<p>${escapeHtml(text)}</p>`);
51
+ }
52
+
53
+ function escapeHtml(value: string): string {
54
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
55
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Listen mode — the durable cursor.
3
+ *
4
+ * The watcher's memory of what it has already answered used to die with the
5
+ * session, which made "switch listen mode on" mean "from now on": whatever
6
+ * arrived while pi was not running was silently dropped. The Telegram bridge
7
+ * solves the same problem with the Bot API's `offset` cursor — Telegram
8
+ * re-delivers every update that has not been acknowledged yet, and the bridge
9
+ * persists that offset, so a restart answers exactly the messages that came in
10
+ * while it was down, and never the same one twice.
11
+ *
12
+ * Graph has no such cursor for chats, so this module keeps one: `chatId → the
13
+ * activity marker of the newest message pi has already looked at`. A chat has
14
+ * moved (and is therefore worth answering) when its current marker differs from
15
+ * the stored one. Written after every tick, so a crash costs nothing but the
16
+ * tick it died in.
17
+ *
18
+ * One file per account **and** tenant, next to the token cache: two accounts in
19
+ * one process must not share a cursor, and neither must a guest tenant beneath
20
+ * the same account.
21
+ */
22
+
23
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { getAgentDir } from "../config/index.ts";
26
+
27
+ /** Shape of the file on disk. Kept small — it is read on every session start. */
28
+ interface WatchCursorFile {
29
+ version: 1;
30
+ /** ISO timestamp of the last write, for humans reading the file */
31
+ updatedAt: string;
32
+ /** chatId → activity marker of the newest message already examined */
33
+ chats: Record<string, string>;
34
+ }
35
+
36
+ export function getWatchCursorDir(): string {
37
+ return join(getAgentDir(), "pi-teams-watch");
38
+ }
39
+
40
+ /**
41
+ * Path of one account+tenant cursor.
42
+ *
43
+ * Sanitised the same way the token cache is, so a hand-written account name
44
+ * cannot escape the directory.
45
+ */
46
+ export function getWatchCursorPath(account: string, tenantId: string): string {
47
+ const clean = (value: string) => value.toLowerCase().replace(/[^a-z0-9._-]/g, "_");
48
+ return join(getWatchCursorDir(), `${clean(account)}__${clean(tenantId)}.json`);
49
+ }
50
+
51
+ /**
52
+ * The cursor for an account+tenant, or `undefined` when there is none yet.
53
+ *
54
+ * `undefined` is meaningful, not just "missing": it means listen mode has never
55
+ * run for this account, which is the one case where history is deliberately
56
+ * skipped instead of answered. A corrupt file is reported the same way — the
57
+ * alternative is a watcher that refuses to start — and the next tick writes a
58
+ * fresh one.
59
+ */
60
+ export function readWatchCursor(account: string, tenantId: string): Map<string, string> | undefined {
61
+ const path = getWatchCursorPath(account, tenantId);
62
+ if (!existsSync(path)) return undefined;
63
+
64
+ try {
65
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as Partial<WatchCursorFile>;
66
+ const chats = parsed?.chats;
67
+ if (parsed?.version !== 1 || !chats || typeof chats !== "object") return undefined;
68
+
69
+ return new Map(
70
+ Object.entries(chats).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
71
+ );
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Persist the cursor. Never throws.
79
+ *
80
+ * Written to a temporary file and renamed, so a crash mid-write leaves the
81
+ * previous cursor intact rather than a half-file that would be read as
82
+ * "no cursor" and replay a whole history.
83
+ */
84
+ export function writeWatchCursor(
85
+ account: string,
86
+ tenantId: string,
87
+ chats: ReadonlyMap<string, string>,
88
+ ): void {
89
+ const path = getWatchCursorPath(account, tenantId);
90
+ const file: WatchCursorFile = {
91
+ version: 1,
92
+ updatedAt: new Date().toISOString(),
93
+ chats: Object.fromEntries(chats),
94
+ };
95
+
96
+ try {
97
+ const dir = getWatchCursorDir();
98
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
99
+
100
+ const tmpPath = `${path}.${process.pid}.tmp`;
101
+ writeFileSync(tmpPath, `${JSON.stringify(file)}\n`, { encoding: "utf-8", mode: 0o600 });
102
+ chmodSync(tmpPath, 0o600);
103
+ renameSync(tmpPath, path);
104
+ } catch {
105
+ /* A cursor that cannot be written costs a repeated examination, not an operation. */
106
+ try {
107
+ unlinkSync(`${path}.${process.pid}.tmp`);
108
+ } catch {
109
+ /* ignore */
110
+ }
111
+ }
112
+ }
@@ -9,6 +9,12 @@
9
9
  *
10
10
  * Two things are deliberately not pure: the clock and the fetch. Both are
11
11
  * injected, which is what makes the decision rules testable without a tenant.
12
+ *
13
+ * `seen` is the durable cursor (see ./cursor.ts): a chat whose marker differs
14
+ * from the stored one has moved since pi last looked, and `isUnread` then says
15
+ * whether the user has already opened it in Teams. That pair is what makes
16
+ * switching listen mode back on answer exactly the messages that are still open
17
+ * — including the ones that arrived while pi was not running.
12
18
  */
13
19
 
14
20
  import type { ChatSummary, MessageSummary, PersonRef, SignedInUser } from "../types.ts";
@@ -24,20 +30,56 @@ import { chatCandidates } from "../graph/mappers.ts";
24
30
  * What the watcher remembers between ticks.
25
31
  *
26
32
  * `seen` is keyed by chat and holds the activity marker of the last message we
27
- * looked at, so a tick can skip every chat that has not moved. It is a
28
- * high-water mark, not a read receipt: nothing here is ever sent to Teams.
33
+ * looked at, so a tick can skip every chat that has not moved. It survives the
34
+ * session on disk, which is what turns "new since the last poll" into "new
35
+ * since pi last looked". It is a high-water mark, not a read receipt: nothing
36
+ * here is ever sent to Teams.
29
37
  */
30
38
  export interface WatchState {
31
- /** chatId → activity marker of the last examined message */
39
+ /** chatId → activity marker of the last examined message, restored from disk */
32
40
  seen: Map<string, string>;
41
+ /**
42
+ * chatId → how often examining it has failed in a row.
43
+ *
44
+ * The cursor is written before the chat is examined, which is what keeps a
45
+ * broken chat from being retried forever — but on its own it would also turn
46
+ * a token renewal or a throttle into a message that is never answered. So a
47
+ * failure un-marks the chat again, up to MAX_CHAT_ATTEMPTS.
48
+ */
49
+ attempts: Map<string, number>;
33
50
  /** chatId → epoch ms of the last wake in that chat */
34
51
  lastTriggered: Map<string, number>;
35
52
  /** epoch ms of every wake, pruned to the last hour */
36
53
  triggers: number[];
37
54
  }
38
55
 
39
- export function createWatchState(): WatchState {
40
- return { seen: new Map(), lastTriggered: new Map(), triggers: [] };
56
+ /**
57
+ * How often one chat may fail before pi stops trying and moves on.
58
+ *
59
+ * Three is enough to ride out a token renewal and a throttling window, and few
60
+ * enough that a chat Graph will never serve does not occupy a batch slot on
61
+ * every tick.
62
+ */
63
+ export const MAX_CHAT_ATTEMPTS = 3;
64
+
65
+ /**
66
+ * @param seen The persisted cursor, when this account has been watched before.
67
+ * Omitted means the very first run, which is the only run that skips history.
68
+ */
69
+ export function createWatchState(seen?: Iterable<[string, string]>): WatchState {
70
+ return { seen: new Map(seen), attempts: new Map(), lastTriggered: new Map(), triggers: [] };
71
+ }
72
+
73
+ /** Record a failed examination; true while the chat is still worth retrying. */
74
+ export function noteFailure(state: WatchState, chatId: string): boolean {
75
+ const attempts = (state.attempts.get(chatId) ?? 0) + 1;
76
+ state.attempts.set(chatId, attempts);
77
+ return attempts < MAX_CHAT_ATTEMPTS;
78
+ }
79
+
80
+ /** Forget the failure count of a chat that was examined successfully. */
81
+ export function noteSuccess(state: WatchState, chatId: string): void {
82
+ state.attempts.delete(chatId);
41
83
  }
42
84
 
43
85
  /** Marker for "this is the newest state of that chat we have looked at". */
@@ -45,6 +87,15 @@ export function activityMarker(chat: ChatSummary): string {
45
87
  return chat.lastUpdated ?? "";
46
88
  }
47
89
 
90
+ /**
91
+ * Upper bound on cursor entries.
92
+ *
93
+ * A user can accumulate chats for years, and the file is read on every session
94
+ * start; a thousand is two orders of magnitude more than the window the watcher
95
+ * polls, so the bound is only ever felt by chats that are long out of sight.
96
+ */
97
+ const MAX_CURSOR_ENTRIES = 1000;
98
+
48
99
  /** How many wakes happened within the last hour. */
49
100
  export function wakesThisHour(state: WatchState, now: number): number {
50
101
  return state.triggers.filter((at) => now - at < 3600_000).length;
@@ -53,14 +104,31 @@ export function wakesThisHour(state: WatchState, now: number): number {
53
104
  /**
54
105
  * Drop bookkeeping that can no longer influence a decision.
55
106
  *
56
- * A session can run for days; without this the two maps grow with every chat
57
- * the user ever touches. The hour is the longest window any rule looks at.
107
+ * A session can run for days; without this the maps grow with every chat the
108
+ * user ever touches. The hour is the longest window any rule looks at.
58
109
  */
59
110
  export function pruneState(state: WatchState, now: number): void {
60
111
  state.triggers = state.triggers.filter((at) => now - at < 3600_000);
61
112
  for (const [chatId, at] of state.lastTriggered) {
62
113
  if (now - at > 24 * 3600_000) state.lastTriggered.delete(chatId);
63
114
  }
115
+
116
+ // The cursor is pruned by size, never by age. Dropping the entry of a chat
117
+ // that has simply been quiet for a long time would make it a candidate again
118
+ // and answer an old message; the count bound only ever touches chats far
119
+ // outside the window the chat list is read in.
120
+ if (state.seen.size > MAX_CURSOR_ENTRIES) {
121
+ const byMarker = [...state.seen.entries()].sort((a, b) => (b[1] ?? "").localeCompare(a[1] ?? ""));
122
+ state.seen = new Map(byMarker.slice(0, MAX_CURSOR_ENTRIES));
123
+ }
124
+
125
+ // Counters are bounded by size rather than by the cursor: a chat that keeps
126
+ // failing is deliberately *not* in the cursor, and its counter is what stops
127
+ // it from being retried forever.
128
+ if (state.attempts.size > MAX_CURSOR_ENTRIES) {
129
+ const overflow = state.attempts.size - MAX_CURSOR_ENTRIES;
130
+ for (const chatId of [...state.attempts.keys()].slice(0, overflow)) state.attempts.delete(chatId);
131
+ }
64
132
  }
65
133
 
66
134
  // ---------------------------------------------------------------------------
@@ -71,28 +139,26 @@ export function pruneState(state: WatchState, now: number): void {
71
139
  * Chats that are both allowed by the watch filter and have moved since we last
72
140
  * looked.
73
141
  *
74
- * @param freshnessMs How far back "new" reaches. Enabling listen mode must not
75
- * wake pi for every conversation that happened to be active this morning, so
76
- * anything older than the window is recorded as seen and then ignored.
142
+ * Two questions are asked in sequence and they are deliberately different:
143
+ * "has this chat moved since pi last examined it?" is this function, and "does
144
+ * the user still need to see it?" is `isUnread`, which needs one Graph call per
145
+ * chat and therefore lives in the loop. Age is part of neither: a message that
146
+ * is still unread is still open, whether it arrived a minute or a week ago.
77
147
  */
78
148
  export function chatsToExamine(
79
149
  state: WatchState,
80
150
  chats: ChatSummary[],
81
151
  watch: ResolvedWatchConfig,
82
- now: number,
83
- freshnessMs: number,
84
152
  ): ChatSummary[] {
85
- const cutoff = now - freshnessMs;
86
-
87
153
  return chats.filter((chat) => {
88
154
  if (!isWatchedChat(chat, watch)) return false;
89
- if (state.seen.get(chat.id) === activityMarker(chat)) return false;
90
155
 
91
- const updated = chat.lastUpdated ? new Date(chat.lastUpdated).getTime() : 0;
92
- // A chat with no activity timestamp cannot be dated, so it is never new.
93
- if (!updated || updated < cutoff) return false;
156
+ const marker = activityMarker(chat);
157
+ // A chat the chat list cannot date has nothing to compare, so it is never
158
+ // "new" — and never marked, which costs no Graph call at all.
159
+ if (!marker) return false;
94
160
 
95
- return true;
161
+ return state.seen.get(chat.id) !== marker;
96
162
  });
97
163
  }
98
164
 
@@ -135,6 +201,28 @@ export type WakeDecision = { wake: true } | { wake: false; reason: string };
135
201
  const WAKE: WakeDecision = { wake: true };
136
202
  const skip = (reason: string): WakeDecision => ({ wake: false, reason });
137
203
 
204
+ /**
205
+ * Is the newest message in this chat still unread for the user?
206
+ *
207
+ * This is the Teams read cursor talking, not pi's own memory: a message the
208
+ * user already opened in Teams does not need an answer from pi, and a message
209
+ * that is still unread does — even if it arrived days ago while pi was not
210
+ * running. Graph withholds the read time for some chat shapes, and a chat we
211
+ * cannot date is not "new" at all; both fall back to answering, because a read
212
+ * state we cannot read must not silently switch listen mode off.
213
+ */
214
+ export function isUnread(chat: ChatSummary, lastMessageReadAt?: string): boolean {
215
+ const marker = activityMarker(chat);
216
+ if (!marker) return false;
217
+ if (!lastMessageReadAt) return true;
218
+
219
+ const read = new Date(lastMessageReadAt).getTime();
220
+ const moved = new Date(marker).getTime();
221
+ if (!Number.isFinite(read) || !Number.isFinite(moved)) return true;
222
+
223
+ return read < moved;
224
+ }
225
+
138
226
  /**
139
227
  * Should this message wake pi?
140
228
  *