niahere 0.5.11 → 0.5.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -37,9 +37,51 @@ export interface CoalescerOptions {
37
37
  maxBatch?: number;
38
38
  /** Defaults to serializing internally, which suits a caller with no lock. */
39
39
  schedule?: Schedule;
40
+ /**
41
+ * Consecutive turns whose reply may be folded forward before one is sent
42
+ * regardless. A room that never goes quiet must still get an answer.
43
+ */
44
+ maxDeferrals?: number;
40
45
  }
41
46
 
42
47
  const DEFAULT_MAX_BATCH = 10;
48
+ const DEFAULT_MAX_DEFERRALS = 2;
49
+
50
+ /**
51
+ * What a running turn can ask about its own standing.
52
+ *
53
+ * Coalescing answers the question "which messages share a turn". It cannot
54
+ * answer "is this reply still worth sending", because the message that
55
+ * obsoletes a reply arrives while the turn that produces it is still running.
56
+ * Roughly one in eight messages to the Slack DM lands mid-turn, and the ones
57
+ * that do are corrections — "i meant browser" sat 49 seconds behind an answer
58
+ * to the question it replaced, and that answer went out first.
59
+ */
60
+ export interface TurnControl {
61
+ /**
62
+ * True when a newer message for this room is already queued, so this reply
63
+ * answers a superseded question. Fold it forward instead of sending: the
64
+ * text stays in the session, so the next turn can restate whatever still
65
+ * matters and answer both in one reply.
66
+ *
67
+ * Decided on first call and stable thereafter — a message landing during
68
+ * delivery must not retroactively unsend a reply.
69
+ */
70
+ superseded(): boolean;
71
+
72
+ /**
73
+ * True when the turn before this one folded its reply forward. That text is
74
+ * in the session, so without being told otherwise the model reads its own
75
+ * undelivered answer and writes "as I said above" about something nobody
76
+ * saw. This turn's reply has to cover both.
77
+ */
78
+ readonly previousReplyWithheld: boolean;
79
+ }
80
+
81
+ interface TurnControlInternal extends TurnControl {
82
+ /** Whether `superseded()` was both asked and answered yes. */
83
+ readonly deferred: boolean;
84
+ }
43
85
 
44
86
  export interface Coalescer {
45
87
  /** Offer a message. Runs now, or joins the next batch. */
@@ -49,12 +91,28 @@ export interface Coalescer {
49
91
  }
50
92
 
51
93
  export function createCoalescer(
52
- process: (batch: Pending[]) => Promise<void>,
94
+ process: (batch: Pending[], turn: TurnControl) => Promise<void>,
53
95
  options: CoalescerOptions = {},
54
96
  ): Coalescer {
55
97
  const maxBatch = Math.max(1, options.maxBatch ?? DEFAULT_MAX_BATCH);
98
+ const maxDeferrals = Math.max(0, options.maxDeferrals ?? DEFAULT_MAX_DEFERRALS);
56
99
  const queue: Pending[] = [];
57
100
  let scheduled = false;
101
+ let deferrals = 0;
102
+
103
+ function control(canDefer: boolean, previousReplyWithheld: boolean): TurnControlInternal {
104
+ let decided: boolean | null = null;
105
+ return {
106
+ previousReplyWithheld,
107
+ superseded() {
108
+ if (decided === null) decided = canDefer && queue.length > 0;
109
+ return decided;
110
+ },
111
+ get deferred() {
112
+ return decided === true;
113
+ },
114
+ };
115
+ }
58
116
 
59
117
  // Two chains, deliberately separate. `lockChain` is the fallback scheduler for
60
118
  // a caller with no lock of its own; `idleChain` only tracks completion so
@@ -78,8 +136,14 @@ export function createCoalescer(
78
136
  scheduled = false;
79
137
  const batch = queue.splice(0, maxBatch);
80
138
  if (batch.length === 0) return;
139
+ // `deferrals` resets whenever a turn replies, so a non-zero count means
140
+ // the turn immediately before this one withheld its reply.
141
+ const turn = control(deferrals < maxDeferrals, deferrals > 0);
81
142
  // A turn that throws must not strand the messages queued behind it.
82
- await process(batch).catch(() => {});
143
+ await process(batch, turn).catch(() => {});
144
+ // A turn that replied clears the run; only folded-forward ones count
145
+ // toward the cap.
146
+ deferrals = turn.deferred ? deferrals + 1 : 0;
83
147
  if (queue.length > 0) kick();
84
148
  } finally {
85
149
  settle();
@@ -108,18 +172,27 @@ export function createCoalescer(
108
172
  * three redundant replies into one *incomplete* reply would be worse than the
109
173
  * behaviour it replaces.
110
174
  */
111
- export function mergeMessages(items: Pending[]): Pending {
175
+ export function mergeMessages(items: Pending[], previousReplyWithheld = false): Pending {
112
176
  const attachments = items.flatMap((i) => i.attachments ?? []);
113
177
  const parts = items.map((i) => i.text.trim()).filter((t) => t.length > 0);
114
178
 
115
- if (items.length === 1) return { text: items[0]!.text, attachments };
116
- if (parts.length === 0) return { text: "", attachments };
117
- if (parts.length === 1) return { text: parts[0]!, attachments };
179
+ const notes: string[] = [];
180
+ if (previousReplyWithheld) notes.push(WITHHELD_NOTE);
181
+ if (parts.length > 1) {
182
+ notes.push(`[${parts.length} messages arrived together while you were working. Answer all of them.]`);
183
+ }
184
+
185
+ const body =
186
+ items.length === 1 ? items[0]!.text : parts.length === 0 ? "" : parts.length === 1 ? parts[0]! : parts.join("\n\n");
118
187
 
119
- const header = `[${parts.length} messages arrived together while you were working. Answer all of them.]`;
120
- return { text: `${header}\n\n${parts.join("\n\n")}`, attachments };
188
+ if (notes.length === 0) return { text: body, attachments };
189
+ return { text: `${notes.join("\n")}\n\n${body}`, attachments };
121
190
  }
122
191
 
192
+ const WITHHELD_NOTE =
193
+ "[Your last reply was not sent — this arrived before it went out, so the user has never seen it. " +
194
+ "Cover anything from it that still matters.]";
195
+
123
196
  /** An inbound message plus whatever the channel needs to answer it. */
124
197
  export interface Inbound<C> extends Pending {
125
198
  ctx: C;
@@ -145,7 +218,7 @@ export interface TurnPump<K, C> {
145
218
  */
146
219
  export function createTurnPump<K, C>(
147
220
  lockFor: (key: K) => Schedule,
148
- run: (key: K, batch: Inbound<C>[], merged: Pending) => Promise<void>,
221
+ run: (key: K, batch: Inbound<C>[], merged: Pending, turn: TurnControl) => Promise<void>,
149
222
  options: CoalescerOptions = {},
150
223
  ): TurnPump<K, C> {
151
224
  const queues = new Map<K, Coalescer>();
@@ -154,8 +227,8 @@ export function createTurnPump<K, C>(
154
227
  let q = queues.get(key);
155
228
  if (!q) {
156
229
  q = createCoalescer(
157
- async (batch) => {
158
- await run(key, batch as Inbound<C>[], mergeMessages(batch));
230
+ async (batch, turn) => {
231
+ await run(key, batch as Inbound<C>[], mergeMessages(batch, turn.previousReplyWithheld), turn);
159
232
  },
160
233
  { ...options, schedule: lockFor(key) },
161
234
  );
@@ -1,3 +1,5 @@
1
+ import type { TurnControl } from "./coalesce";
2
+
1
3
  /**
2
4
  * What counts as a reply, and what is just the model talking to itself.
3
5
  *
@@ -77,3 +79,39 @@ export function decideWatchReply(structured: unknown, raw: string): WatchDecisio
77
79
  }
78
80
  return { send: true, text: trimmed, source: "sentinel" };
79
81
  }
82
+
83
+ export interface Delivery {
84
+ /** Whether to put the text in front of the reader now. */
85
+ post: boolean;
86
+ /** The reply text. Kept even when held back, so the caller can log it. */
87
+ text: string;
88
+ /** Which path judged the reply. */
89
+ source: WatchDecision["source"];
90
+ /** Why it is not being posted. */
91
+ reason?: "silent" | "ambiguous" | "superseded";
92
+ }
93
+
94
+ /**
95
+ * The whole judgement on a finished turn's reply: is there anything to say,
96
+ * and is it still worth saying.
97
+ *
98
+ * Order matters. A turn that chose silence is never asked whether it was
99
+ * superseded — it withheld nothing, and most watch-channel turns are silent,
100
+ * so charging them a deferral would exhaust the cap on rooms that never
101
+ * deferred anything.
102
+ */
103
+ export function decideDelivery(structured: unknown, raw: string, turn: Pick<TurnControl, "superseded">): Delivery {
104
+ const decision = decideWatchReply(structured, raw);
105
+ if (!decision.send) {
106
+ return {
107
+ post: false,
108
+ text: decision.text,
109
+ source: decision.source,
110
+ reason: decision.ambiguous ? "ambiguous" : "silent",
111
+ };
112
+ }
113
+ if (turn.superseded()) {
114
+ return { post: false, text: decision.text, source: decision.source, reason: "superseded" };
115
+ }
116
+ return { post: true, text: decision.text, source: decision.source };
117
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Channel } from "../types";
2
- import { registerChannel, getFactories, trackStarted, clearStarted, getStarted } from "./registry";
2
+ import { registerChannel, getFactories, trackStarted, clearStarted, getStarted, untrackStarted } from "./registry";
3
3
  import { log } from "../utils/log";
4
+ import { ignore } from "../utils/errors";
4
5
  import { getConfig } from "../utils/config";
5
6
  import { createTelegramChannel } from "./telegram";
6
7
  import { createSlackChannel } from "./slack";
@@ -20,6 +21,8 @@ export function registerAllChannels(): void {
20
21
  registerChannel(() => createWhatsAppChannel());
21
22
  }
22
23
 
24
+ let reconciling = false;
25
+
23
26
  export interface StartResult {
24
27
  started: Channel[];
25
28
  failed: string[];
@@ -72,8 +75,26 @@ export async function startChannels(only?: readonly string[]): Promise<StartResu
72
75
  * `startChannels` abandons the failed channels with no retry, so without
73
76
  * reconciliation Nia stays alive but deaf on every channel until a manual
74
77
  * restart. The alive monitor calls this every healthy heartbeat.
78
+ *
79
+ * It also covers the reverse case, where a channel is tracked as running but
80
+ * its transport has died underneath it. Being in the registry was the only
81
+ * liveness anyone checked, so such a channel was never retried; `healthy()`
82
+ * lets a channel say otherwise and get rebuilt.
75
83
  */
76
84
  export async function reconcileChannels(): Promise<StartResult> {
85
+ // Bringing a channel up can block for as long as its transport takes to
86
+ // connect. Without this, a slow start would let the next heartbeat reconcile
87
+ // the same channel again and leave two of them running.
88
+ if (reconciling) return { started: [], failed: [] };
89
+ reconciling = true;
90
+ try {
91
+ return await reconcile();
92
+ } finally {
93
+ reconciling = false;
94
+ }
95
+ }
96
+
97
+ async function reconcile(): Promise<StartResult> {
77
98
  const wanted = getConfiguredChannelNames();
78
99
  const running = getStarted();
79
100
  const runningNames = new Set(running.map((ch) => ch.name));
@@ -81,9 +102,11 @@ export async function reconcileChannels(): Promise<StartResult> {
81
102
 
82
103
  const missing = wanted.filter((name) => !runningNames.has(name));
83
104
  const extra = running.filter((ch) => !wantedSet.has(ch.name));
84
- if (missing.length === 0 && extra.length === 0) return { started: [], failed: [] };
105
+ const dead = running.filter((ch) => wantedSet.has(ch.name) && ch.healthy?.() === false);
106
+ if (missing.length === 0 && extra.length === 0 && dead.length === 0) return { started: [], failed: [] };
85
107
 
86
- log.warn({ missing, extra: extra.map((ch) => ch.name) }, "channels out of sync, reconciling");
108
+ const deadNames = dead.map((ch) => ch.name);
109
+ log.warn({ missing, extra: extra.map((ch) => ch.name), dead: deadNames }, "channels out of sync, reconciling");
87
110
 
88
111
  // A channel was removed from config. stopChannels() tears down the whole
89
112
  // registry (it stops the shared Twilio server and clears all tracking), so a
@@ -93,9 +116,16 @@ export async function reconcileChannels(): Promise<StartResult> {
93
116
  return wanted.length > 0 ? startChannels() : { started: [], failed: [] };
94
117
  }
95
118
 
96
- // Only additions: start just the missing channels so healthy ones stay
97
- // connected and one persistently-failing channel can't thrash the rest.
98
- return startChannels(missing);
119
+ // Tear the dead ones down individually so healthy channels stay connected. A
120
+ // stop that fails must not block the restart — the point is to replace them.
121
+ for (const channel of dead) {
122
+ await ignore(channel.stop(), `stopping dead channel ${channel.name}`);
123
+ untrackStarted(channel.name);
124
+ }
125
+
126
+ // Start just the channels that need it so healthy ones stay connected and one
127
+ // persistently-failing channel can't thrash the rest.
128
+ return startChannels([...missing, ...deadNames]);
99
129
  }
100
130
 
101
131
  export function getConfiguredChannelNames(): string[] {
@@ -11,6 +11,10 @@ export function getFactories(): readonly ChannelFactory[] {
11
11
  return factories;
12
12
  }
13
13
 
14
+ export function clearFactories(): void {
15
+ factories.length = 0;
16
+ }
17
+
14
18
  export function trackStarted(channel: Channel): void {
15
19
  started.set(channel.name, channel);
16
20
  }
@@ -23,6 +27,10 @@ export function getStarted(): Channel[] {
23
27
  return [...started.values()];
24
28
  }
25
29
 
30
+ export function untrackStarted(name: string): void {
31
+ started.delete(name);
32
+ }
33
+
26
34
  export function clearStarted(): void {
27
35
  started.clear();
28
36
  }
@@ -1,4 +1,4 @@
1
- import { App } from "@slack/bolt";
1
+ import { App, SocketModeReceiver } from "@slack/bolt";
2
2
  import type { WebClient } from "@slack/web-api";
3
3
  import type { Channel, ChatState, Attachment, Outbound, Recipient } from "../types";
4
4
  import { getConfig, updateRawConfig } from "../utils/config";
@@ -12,11 +12,50 @@ import { ChatSessions, chainLock } from "./common/chat-session";
12
12
  import { SlackAttachmentCache } from "./slack/attachments";
13
13
  import { SlackWatchReloader } from "./slack/watch";
14
14
 
15
- import { decideWatchReply, shouldSuppressReply } from "./common/reply";
15
+ import { decideDelivery, shouldSuppressReply } from "./common/reply";
16
16
  import { createTurnPump } from "./common/coalesce";
17
17
 
18
18
  const logActivity = (status: string) => log.debug({ status }, "slack engine activity");
19
19
 
20
+ /** A healthy reconnect lands in seconds, so a gap this long is not one in progress. */
21
+ const SOCKET_STALL_MS = 3 * 60_000;
22
+
23
+ /** Every Socket Mode state that is not "carrying events right now". */
24
+ const DISCONNECTED_STATES = ["connecting", "reconnecting", "disconnecting", "disconnected"] as const;
25
+
26
+ interface SocketStateEmitter {
27
+ on(event: string, listener: () => void): unknown;
28
+ }
29
+
30
+ /**
31
+ * Whether Socket Mode is actually carrying events, as opposed to merely having
32
+ * been started.
33
+ *
34
+ * Socket Mode can stop trying without saying so: a transport-level failure of
35
+ * `apps.connections.open` is classed unrecoverable, and the resulting rejection
36
+ * escapes an unawaited reconnect chain. The process stays up and the tokens stay
37
+ * valid, so nothing outside the socket can tell it is never coming back.
38
+ */
39
+ export class SocketLiveness {
40
+ private downSince: number | null = null;
41
+
42
+ constructor(client: SocketStateEmitter) {
43
+ client.on("connected", () => {
44
+ if (this.downSince !== null) log.info("slack: socket connected");
45
+ this.downSince = null;
46
+ });
47
+ for (const state of DISCONNECTED_STATES) {
48
+ client.on(state, () => {
49
+ this.downSince ??= Date.now();
50
+ });
51
+ }
52
+ }
53
+
54
+ stalled(now = Date.now()): boolean {
55
+ return this.downSince !== null && now - this.downSince >= SOCKET_STALL_MS;
56
+ }
57
+ }
58
+
20
59
  /** What answering a Slack message needs, carried per message so a coalesced
21
60
  * turn can still reply in the right thread and clear every reaction. */
22
61
  interface SlackTurnCtx {
@@ -51,6 +90,11 @@ class SlackChannel implements Channel {
51
90
  private dmUserId: string | null = null;
52
91
  /** Timestamps of messages Nia posted proactively (used to detect replies to our own messages) */
53
92
  private outboundTs = new Set<string>();
93
+ private liveness: SocketLiveness | null = null;
94
+
95
+ healthy(): boolean {
96
+ return !this.liveness?.stalled();
97
+ }
54
98
 
55
99
  async deliver(out: Outbound): Promise<void> {
56
100
  if (!this.app) throw new Error("Slack not started");
@@ -155,7 +199,7 @@ class SlackChannel implements Channel {
155
199
  */
156
200
  const slackPump = createTurnPump<string, SlackTurnCtx>(
157
201
  (key) => (fn) => withLock(key, fn),
158
- async (key, batch, merged) => {
202
+ async (key, batch, merged, turn) => {
159
203
  // The newest message decides where the reply goes; every message in the
160
204
  // batch needs its own reaction cleared.
161
205
  const last = batch[batch.length - 1]!.ctx;
@@ -222,17 +266,23 @@ class SlackChannel implements Channel {
222
266
  return;
223
267
  }
224
268
 
225
- const decision = decideWatchReply(structured, result);
226
- const reply = decision.text;
269
+ const delivery = decideDelivery(structured, result, turn);
270
+ const reply = delivery.text;
227
271
 
228
- if (!decision.send) {
229
- if (decision.ambiguous) {
272
+ if (!delivery.post) {
273
+ if (delivery.reason === "ambiguous") {
230
274
  log.warn(
231
- { channel: msg.channel, key, reply: result.trim(), source: decision.source },
275
+ { channel: msg.channel, key, reply: result.trim(), source: delivery.source },
232
276
  "slack: [NO_REPLY] sentinel mixed with content; suppressing send",
233
277
  );
278
+ } else if (delivery.reason === "superseded") {
279
+ // The text stays in the session, so the next turn answers both.
280
+ log.info(
281
+ { channel: msg.channel, key, chars: reply.length },
282
+ "slack: reply superseded by a newer message; folding into the next turn",
283
+ );
234
284
  } else {
235
- log.info({ channel: msg.channel, key, source: decision.source }, "slack: agent chose not to reply");
285
+ log.info({ channel: msg.channel, key, source: delivery.source }, "slack: agent chose not to reply");
236
286
  }
237
287
  if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
238
288
  return;
@@ -269,10 +319,14 @@ class SlackChannel implements Channel {
269
319
 
270
320
  const self = this;
271
321
 
322
+ const receiver = new SocketModeReceiver({ appToken });
323
+ this.liveness = new SocketLiveness(receiver.client);
324
+
272
325
  const app = new App({
273
326
  token: botToken,
274
327
  appToken,
275
328
  socketMode: true,
329
+ receiver,
276
330
  });
277
331
 
278
332
  let botUserId: string | undefined;
@@ -566,6 +620,7 @@ class SlackChannel implements Channel {
566
620
  if (this.app) {
567
621
  await this.app.stop();
568
622
  this.app = null;
623
+ this.liveness = null;
569
624
  }
570
625
  }
571
626
  }
@@ -31,6 +31,13 @@ export interface Channel {
31
31
  name: string;
32
32
  start(): Promise<void>;
33
33
  stop(): Promise<void>;
34
+ /**
35
+ * Whether the channel can still receive. Implement it wherever having started
36
+ * is not the same as being connected — a transport can die under a process
37
+ * that stays up, and no caller above this seam can tell. Omit it and the
38
+ * channel is assumed healthy for as long as it is running.
39
+ */
40
+ healthy?(): boolean;
34
41
  /**
35
42
  * Deliver an outbound payload. Channels are expected to handle either
36
43
  * a text-only, media-only, or text+media payload; format details (chunking,