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 +1 -1
- package/src/agent/auth.ts +4 -1
- package/src/channels/common/coalesce.ts +84 -11
- package/src/channels/common/reply.ts +38 -0
- package/src/channels/slack.ts +14 -8
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
116
|
-
if (
|
|
117
|
-
if (parts.length
|
|
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
|
-
|
|
120
|
-
return { text: `${
|
|
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
|
+
}
|
package/src/channels/slack.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
226
|
-
const reply =
|
|
225
|
+
const delivery = decideDelivery(structured, result, turn);
|
|
226
|
+
const reply = delivery.text;
|
|
227
227
|
|
|
228
|
-
if (!
|
|
229
|
-
if (
|
|
228
|
+
if (!delivery.post) {
|
|
229
|
+
if (delivery.reason === "ambiguous") {
|
|
230
230
|
log.warn(
|
|
231
|
-
{ channel: msg.channel, key, reply: result.trim(), 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:
|
|
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;
|