niahere 0.5.10 → 0.5.12

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.10",
3
+ "version": "0.5.12",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/agent/auth.ts CHANGED
@@ -144,7 +144,10 @@ export function codexAuthStatus(now: number = Date.now(), reader: AuthReader = d
144
144
  }
145
145
 
146
146
  const tokens = (auth.tokens ?? {}) as Record<string, unknown>;
147
- const exp = jwtExpiry(typeof tokens.id_token === "string" ? tokens.id_token : undefined);
147
+ // access_token is what authenticates. id_token is a short-lived OIDC identity
148
+ // token that lapses about an hour after every refresh, so reading it reports
149
+ // "lapsed" for any healthy install — a permanent false alarm.
150
+ const exp = jwtExpiry(typeof tokens.access_token === "string" ? tokens.access_token : undefined);
148
151
  const mode = typeof auth.auth_mode === "string" ? auth.auth_mode : "oauth";
149
152
  if (exp === undefined) return { ...base, state: "unknown", detail: `${mode} sign-in, no readable expiry` };
150
153
  if (exp <= now) return { ...base, state: "stale", detail: `${mode} token lapsed ${ago(now - exp)} ago, renewable` };
@@ -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
+ }
@@ -12,7 +12,7 @@ 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");
@@ -155,7 +155,7 @@ class SlackChannel implements Channel {
155
155
  */
156
156
  const slackPump = createTurnPump<string, SlackTurnCtx>(
157
157
  (key) => (fn) => withLock(key, fn),
158
- async (key, batch, merged) => {
158
+ async (key, batch, merged, turn) => {
159
159
  // The newest message decides where the reply goes; every message in the
160
160
  // batch needs its own reaction cleared.
161
161
  const last = batch[batch.length - 1]!.ctx;
@@ -222,17 +222,23 @@ class SlackChannel implements Channel {
222
222
  return;
223
223
  }
224
224
 
225
- const decision = decideWatchReply(structured, result);
226
- const reply = decision.text;
225
+ const delivery = decideDelivery(structured, result, turn);
226
+ const reply = delivery.text;
227
227
 
228
- if (!decision.send) {
229
- if (decision.ambiguous) {
228
+ if (!delivery.post) {
229
+ if (delivery.reason === "ambiguous") {
230
230
  log.warn(
231
- { channel: msg.channel, key, reply: result.trim(), source: decision.source },
231
+ { channel: msg.channel, key, reply: result.trim(), source: delivery.source },
232
232
  "slack: [NO_REPLY] sentinel mixed with content; suppressing send",
233
233
  );
234
+ } else if (delivery.reason === "superseded") {
235
+ // The text stays in the session, so the next turn answers both.
236
+ log.info(
237
+ { channel: msg.channel, key, chars: reply.length },
238
+ "slack: reply superseded by a newer message; folding into the next turn",
239
+ );
234
240
  } else {
235
- log.info({ channel: msg.channel, key, source: decision.source }, "slack: agent chose not to reply");
241
+ log.info({ channel: msg.channel, key, source: delivery.source }, "slack: agent chose not to reply");
236
242
  }
237
243
  if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
238
244
  return;