instar 1.3.443 → 1.3.444

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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * AmbientContributionGate — the conservative "should I speak?" gate for Slack's
3
+ * `considered`/ambient mode (Slack org integration, Pillar 1 §5.2 / §6.9).
4
+ *
5
+ * In `mention-only` mode an UNDIRECTED message (no @mention, not a DM) is dropped
6
+ * — the agent stays quiet. Ambient mode keeps that default but adds ONE narrow
7
+ * escape: for a channel that has EXPLICITLY opted into proactive contribution, an
8
+ * undirected message may run this gate, which decides whether the agent has a
9
+ * concrete, meaningful contribution worth making unprompted.
10
+ *
11
+ * ── THE INVARIANT: FAIL-TO-SILENCE ─────────────────────────────────────────────
12
+ *
13
+ * This gate can only ever make the agent QUIETER. It returns `speak: true` ONLY
14
+ * when EVERY one of these holds:
15
+ * (a) the channel is explicitly ambient-opted-in (config — default OFF everywhere),
16
+ * (b) a hard per-channel rate-limit for the rolling window is NOT exceeded,
17
+ * (c) an LLM judges it can contribute MEANINGFULLY above a conservative
18
+ * confidence threshold AND explicitly says to speak.
19
+ *
20
+ * ANY failure, uncertainty, LLM error, missing provider, unparseable verdict, or
21
+ * rate-limit breach → `speak: false`. There is NO path through this gate that
22
+ * produces an over-speak on a degraded condition. This is the deliberate MIRROR of
23
+ * the floor gate's fail-CLOSED (deny-on-error): here the safe direction is SILENCE.
24
+ * (Spec §5.2 "Fail mode: fail to silence"; §11 "the ambient/should-speak gate fails
25
+ * to silence".)
26
+ *
27
+ * ── DARK / OPT-IN ──────────────────────────────────────────────────────────────
28
+ *
29
+ * Ambient contribution is disabled for every channel by default. With NO ambient
30
+ * config, this gate is never even constructed/consulted and `_handleMessage`
31
+ * behaves byte-for-byte as today (mention-only drops undirected messages). The gate
32
+ * runs ONLY for an explicitly-opted-in channel.
33
+ *
34
+ * The LLM is reached through instar's internal `IntelligenceProvider` (the same
35
+ * injected-provider pattern as `LlmIntentClassifier` / `MessagingToneGate`), NOT a
36
+ * raw API call. A `fast` model tier is used. The call is intentionally NOT marked
37
+ * `gating: true`: a gating call would provider-SWAP on failure to keep an authority
38
+ * decision alive, but the safe failure here is to stay silent — we WANT the error to
39
+ * land in our catch and return `speak: false`, never to escalate to keep speaking.
40
+ *
41
+ * NOTE: this gate decides ONLY whether to PROCESS an undirected message. It performs
42
+ * no Slack Web API calls and sends nothing itself — `_handleMessage` does the
43
+ * processing/sending downstream exactly as it does for a directed message.
44
+ *
45
+ * Design: docs/specs/SLACK-ORG-INTEGRATION-SPEC.md §5.2, §6.9, §11.
46
+ */
47
+ import type { IntelligenceProvider } from '../core/types.js';
48
+ /** The gate's decision for a single undirected message. */
49
+ export interface AmbientDecision {
50
+ /** True ONLY when every fail-to-silence condition holds. Default false. */
51
+ speak: boolean;
52
+ /** Machine-readable reason for the decision (for logging / FP measurement). */
53
+ reason: AmbientDecisionReason;
54
+ /** Optional human-readable detail (e.g. the LLM's named contribution). */
55
+ detail?: string;
56
+ }
57
+ export type AmbientDecisionReason = 'channel-not-opted-in' | 'rate-limited' | 'no-intelligence' | 'llm-error' | 'llm-unparseable' | 'llm-declined' | 'low-confidence' | 'speak';
58
+ /** Per-channel ambient configuration. Default: ambient OFF. */
59
+ export interface AmbientChannelConfig {
60
+ /** Channels explicitly opted into proactive contribution. Default: none. */
61
+ enabledChannelIds?: string[];
62
+ /**
63
+ * Hard cap on proactive (unsolicited) messages per channel within the rolling
64
+ * window. Conservative default: 1. A bot that barges in is worse than a silent
65
+ * one, so this is deliberately tiny.
66
+ */
67
+ maxProactivePerChannel?: number;
68
+ /** Rolling rate-limit window in ms. Default: 30 minutes. */
69
+ windowMs?: number;
70
+ /**
71
+ * Conservative confidence floor for the LLM's "speak" verdict, in [0,1]. Below
72
+ * this, the gate stays silent even if the LLM said speak. Default: 0.85 (high bar).
73
+ */
74
+ minConfidence?: number;
75
+ }
76
+ export interface AmbientContributionGateDeps {
77
+ /** Per-channel ambient configuration. Absent/empty ⇒ ambient OFF everywhere. */
78
+ config?: AmbientChannelConfig;
79
+ /**
80
+ * The internal LLM provider (injected — never a direct framework import). When
81
+ * absent, the gate stays SILENT (fail-to-silence): no provider ⇒ no contribution.
82
+ */
83
+ intelligence?: IntelligenceProvider;
84
+ /** Per-call LLM timeout (ms). Default 8000. */
85
+ timeoutMs?: number;
86
+ /**
87
+ * Optional observability hook — fires on EVERY decision with the reason, so the
88
+ * FP-rate of proactive speaking can be measured before any aggressiveness change.
89
+ * Best-effort: it never affects the returned decision.
90
+ */
91
+ onDecision?: (decision: AmbientDecision, channelId: string) => void;
92
+ /** Injectable clock for deterministic tests. Defaults to Date.now. */
93
+ now?: () => number;
94
+ }
95
+ /** Context the gate needs about the channel + message. */
96
+ export interface AmbientGateInput {
97
+ /** The Slack channel id the undirected message arrived in. */
98
+ channelId: string;
99
+ /** The (cleaned) message text. */
100
+ text: string;
101
+ /** Optional channel name, purely for the LLM prompt context. */
102
+ channelName?: string;
103
+ }
104
+ export declare class AmbientContributionGate {
105
+ private readonly enabledChannels;
106
+ private readonly maxProactive;
107
+ private readonly windowMs;
108
+ private readonly minConfidence;
109
+ private readonly intelligence?;
110
+ private readonly timeoutMs;
111
+ private readonly onDecision?;
112
+ private readonly now;
113
+ /**
114
+ * Rate-limit state lives HERE — a per-channel in-memory ring of the timestamps at
115
+ * which the gate last returned speak=true. It is recorded by recordSpoke() (called
116
+ * by _handleMessage only AFTER the gate cleared and the message is actually being
117
+ * processed), so a speak=true that the caller decides not to act on does not
118
+ * consume budget. In-memory is acceptable: a restart resetting the window can only
119
+ * make the agent quieter for a moment (it never over-speaks), which is on the safe
120
+ * side of the invariant. A future durable counter could replace this without
121
+ * changing the contract.
122
+ */
123
+ private readonly proactiveTimestamps;
124
+ constructor(deps?: AmbientContributionGateDeps);
125
+ /** Is ANY channel opted into ambient contribution? Used to skip the gate entirely. */
126
+ isAnyChannelEnabled(): boolean;
127
+ /** Is this specific channel opted into ambient contribution? */
128
+ isChannelEnabled(channelId: string): boolean;
129
+ /**
130
+ * Decide whether to speak on an UNDIRECTED message in an ambient channel.
131
+ * FAIL-TO-SILENCE: returns { speak: false } on every degraded/uncertain path.
132
+ */
133
+ shouldSpeak(input: AmbientGateInput): Promise<AmbientDecision>;
134
+ /**
135
+ * Record that the agent actually spoke proactively in a channel (call AFTER the
136
+ * gate cleared and _handleMessage commits to processing). Consumes one unit of the
137
+ * rolling window budget. Idempotency is not required — each proactive turn is one
138
+ * unit.
139
+ */
140
+ recordSpoke(channelId: string): void;
141
+ /** How many proactive sends remain in the current window for a channel (for status). */
142
+ remainingBudget(channelId: string): number;
143
+ /** True iff the per-channel proactive budget is exhausted for the rolling window. */
144
+ private isRateLimited;
145
+ /** Count of proactive sends within the rolling window; prunes expired entries. */
146
+ private recentCount;
147
+ private decide;
148
+ }
149
+ //# sourceMappingURL=AmbientContributionGate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AmbientContributionGate.d.ts","sourceRoot":"","sources":["../../src/permissions/AmbientContributionGate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AAEH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAE7D,2DAA2D;AAC3D,MAAM,WAAW,eAAe;IAC9B,2EAA2E;IAC3E,KAAK,EAAE,OAAO,CAAC;IACf,+EAA+E;IAC/E,MAAM,EAAE,qBAAqB,CAAC;IAC9B,0EAA0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,qBAAqB,GAC7B,sBAAsB,GACtB,cAAc,GACd,iBAAiB,GACjB,WAAW,GACX,iBAAiB,GACjB,cAAc,GACd,gBAAgB,GAChB,OAAO,CAAC;AAEZ,+DAA+D;AAC/D,MAAM,WAAW,oBAAoB;IACnC,4EAA4E;IAC5E,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC7B;;;;OAIG;IACH,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,2BAA2B;IAC1C,gFAAgF;IAChF,MAAM,CAAC,EAAE,oBAAoB,CAAC;IAC9B;;;OAGG;IACH,YAAY,CAAC,EAAE,oBAAoB,CAAC;IACpC,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACpE,sEAAsE;IACtE,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,0DAA0D;AAC1D,MAAM,WAAW,gBAAgB;IAC/B,8DAA8D;IAC9D,SAAS,EAAE,MAAM,CAAC;IAClB,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,gEAAgE;IAChE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAYD,qBAAa,uBAAuB;IAClC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAc;IAC9C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAuB;IACrD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAyD;IACrF,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IAEnC;;;;;;;;;OASG;IACH,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAoC;gBAE5D,IAAI,GAAE,2BAAgC;IAalD,sFAAsF;IACtF,mBAAmB,IAAI,OAAO;IAI9B,gEAAgE;IAChE,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAI5C;;;OAGG;IACG,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC;IA0DpE;;;;;OAKG;IACH,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAMpC,wFAAwF;IACxF,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM;IAK1C,qFAAqF;IACrF,OAAO,CAAC,aAAa;IAIrB,kFAAkF;IAClF,OAAO,CAAC,WAAW;IAanB,OAAO,CAAC,MAAM;CAQf"}
@@ -0,0 +1,261 @@
1
+ /**
2
+ * AmbientContributionGate — the conservative "should I speak?" gate for Slack's
3
+ * `considered`/ambient mode (Slack org integration, Pillar 1 §5.2 / §6.9).
4
+ *
5
+ * In `mention-only` mode an UNDIRECTED message (no @mention, not a DM) is dropped
6
+ * — the agent stays quiet. Ambient mode keeps that default but adds ONE narrow
7
+ * escape: for a channel that has EXPLICITLY opted into proactive contribution, an
8
+ * undirected message may run this gate, which decides whether the agent has a
9
+ * concrete, meaningful contribution worth making unprompted.
10
+ *
11
+ * ── THE INVARIANT: FAIL-TO-SILENCE ─────────────────────────────────────────────
12
+ *
13
+ * This gate can only ever make the agent QUIETER. It returns `speak: true` ONLY
14
+ * when EVERY one of these holds:
15
+ * (a) the channel is explicitly ambient-opted-in (config — default OFF everywhere),
16
+ * (b) a hard per-channel rate-limit for the rolling window is NOT exceeded,
17
+ * (c) an LLM judges it can contribute MEANINGFULLY above a conservative
18
+ * confidence threshold AND explicitly says to speak.
19
+ *
20
+ * ANY failure, uncertainty, LLM error, missing provider, unparseable verdict, or
21
+ * rate-limit breach → `speak: false`. There is NO path through this gate that
22
+ * produces an over-speak on a degraded condition. This is the deliberate MIRROR of
23
+ * the floor gate's fail-CLOSED (deny-on-error): here the safe direction is SILENCE.
24
+ * (Spec §5.2 "Fail mode: fail to silence"; §11 "the ambient/should-speak gate fails
25
+ * to silence".)
26
+ *
27
+ * ── DARK / OPT-IN ──────────────────────────────────────────────────────────────
28
+ *
29
+ * Ambient contribution is disabled for every channel by default. With NO ambient
30
+ * config, this gate is never even constructed/consulted and `_handleMessage`
31
+ * behaves byte-for-byte as today (mention-only drops undirected messages). The gate
32
+ * runs ONLY for an explicitly-opted-in channel.
33
+ *
34
+ * The LLM is reached through instar's internal `IntelligenceProvider` (the same
35
+ * injected-provider pattern as `LlmIntentClassifier` / `MessagingToneGate`), NOT a
36
+ * raw API call. A `fast` model tier is used. The call is intentionally NOT marked
37
+ * `gating: true`: a gating call would provider-SWAP on failure to keep an authority
38
+ * decision alive, but the safe failure here is to stay silent — we WANT the error to
39
+ * land in our catch and return `speak: false`, never to escalate to keep speaking.
40
+ *
41
+ * NOTE: this gate decides ONLY whether to PROCESS an undirected message. It performs
42
+ * no Slack Web API calls and sends nothing itself — `_handleMessage` does the
43
+ * processing/sending downstream exactly as it does for a directed message.
44
+ *
45
+ * Design: docs/specs/SLACK-ORG-INTEGRATION-SPEC.md §5.2, §6.9, §11.
46
+ */
47
+ const DEFAULT_MAX_PROACTIVE = 1;
48
+ const DEFAULT_WINDOW_MS = 30 * 60 * 1000; // 30 minutes
49
+ const DEFAULT_MIN_CONFIDENCE = 0.85;
50
+ export class AmbientContributionGate {
51
+ enabledChannels;
52
+ maxProactive;
53
+ windowMs;
54
+ minConfidence;
55
+ intelligence;
56
+ timeoutMs;
57
+ onDecision;
58
+ now;
59
+ /**
60
+ * Rate-limit state lives HERE — a per-channel in-memory ring of the timestamps at
61
+ * which the gate last returned speak=true. It is recorded by recordSpoke() (called
62
+ * by _handleMessage only AFTER the gate cleared and the message is actually being
63
+ * processed), so a speak=true that the caller decides not to act on does not
64
+ * consume budget. In-memory is acceptable: a restart resetting the window can only
65
+ * make the agent quieter for a moment (it never over-speaks), which is on the safe
66
+ * side of the invariant. A future durable counter could replace this without
67
+ * changing the contract.
68
+ */
69
+ proactiveTimestamps = new Map();
70
+ constructor(deps = {}) {
71
+ const cfg = deps.config ?? {};
72
+ this.enabledChannels = new Set(cfg.enabledChannelIds ?? []);
73
+ // Nullish coalescing (zero is a valid — fully-silent — cap).
74
+ this.maxProactive = cfg.maxProactivePerChannel ?? DEFAULT_MAX_PROACTIVE;
75
+ this.windowMs = cfg.windowMs ?? DEFAULT_WINDOW_MS;
76
+ this.minConfidence = cfg.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
77
+ this.intelligence = deps.intelligence;
78
+ this.timeoutMs = deps.timeoutMs ?? 8000;
79
+ this.onDecision = deps.onDecision;
80
+ this.now = deps.now ?? (() => Date.now());
81
+ }
82
+ /** Is ANY channel opted into ambient contribution? Used to skip the gate entirely. */
83
+ isAnyChannelEnabled() {
84
+ return this.enabledChannels.size > 0;
85
+ }
86
+ /** Is this specific channel opted into ambient contribution? */
87
+ isChannelEnabled(channelId) {
88
+ return this.enabledChannels.has(channelId);
89
+ }
90
+ /**
91
+ * Decide whether to speak on an UNDIRECTED message in an ambient channel.
92
+ * FAIL-TO-SILENCE: returns { speak: false } on every degraded/uncertain path.
93
+ */
94
+ async shouldSpeak(input) {
95
+ // (a) Channel opt-in. Default OFF — an un-opted channel never speaks.
96
+ if (!this.enabledChannels.has(input.channelId)) {
97
+ return this.decide(input.channelId, { speak: false, reason: 'channel-not-opted-in' });
98
+ }
99
+ // (b) Hard rate-limit. Budget exhausted in the rolling window → silent.
100
+ if (this.isRateLimited(input.channelId)) {
101
+ return this.decide(input.channelId, { speak: false, reason: 'rate-limited' });
102
+ }
103
+ // (c) LLM judgment. No provider → silent (we never speak on a heuristic guess).
104
+ if (!this.intelligence) {
105
+ return this.decide(input.channelId, { speak: false, reason: 'no-intelligence' });
106
+ }
107
+ let raw;
108
+ try {
109
+ const { systemPrompt, userPrompt } = buildAmbientPrompt(input);
110
+ raw = await this.intelligence.evaluate(`${systemPrompt}\n\n${userPrompt}`, {
111
+ model: 'fast',
112
+ temperature: 0,
113
+ maxTokens: 200,
114
+ timeoutMs: this.timeoutMs,
115
+ attribution: {
116
+ component: 'AmbientContributionGate',
117
+ category: 'gate',
118
+ // Deliberately NOT gating:true. A gating call provider-SWAPS on failure to
119
+ // keep an AUTHORITY decision alive; here the safe failure is SILENCE, so we
120
+ // let the error reach the catch below and return speak:false. Escalating to
121
+ // keep talking would be exactly the over-speak this invariant forbids.
122
+ },
123
+ });
124
+ }
125
+ catch {
126
+ // network/timeout/provider failure / circuit open → SILENCE (never escalate).
127
+ return this.decide(input.channelId, { speak: false, reason: 'llm-error' });
128
+ }
129
+ const parsed = parseAmbientVerdict(raw);
130
+ if (!parsed) {
131
+ // Unparseable LLM output is a judgment FAILURE → silence.
132
+ return this.decide(input.channelId, { speak: false, reason: 'llm-unparseable' });
133
+ }
134
+ // The LLM must explicitly say speak.
135
+ if (!parsed.speak) {
136
+ return this.decide(input.channelId, { speak: false, reason: 'llm-declined' });
137
+ }
138
+ // Conservative confidence bar. Below it (or no named contribution) → silence.
139
+ if (parsed.confidence < this.minConfidence || !parsed.contribution) {
140
+ return this.decide(input.channelId, { speak: false, reason: 'low-confidence', detail: parsed.contribution });
141
+ }
142
+ // ALL fail-to-silence conditions held → speak.
143
+ return this.decide(input.channelId, { speak: true, reason: 'speak', detail: parsed.contribution });
144
+ }
145
+ /**
146
+ * Record that the agent actually spoke proactively in a channel (call AFTER the
147
+ * gate cleared and _handleMessage commits to processing). Consumes one unit of the
148
+ * rolling window budget. Idempotency is not required — each proactive turn is one
149
+ * unit.
150
+ */
151
+ recordSpoke(channelId) {
152
+ const arr = this.proactiveTimestamps.get(channelId) ?? [];
153
+ arr.push(this.now());
154
+ this.proactiveTimestamps.set(channelId, arr);
155
+ }
156
+ /** How many proactive sends remain in the current window for a channel (for status). */
157
+ remainingBudget(channelId) {
158
+ const used = this.recentCount(channelId);
159
+ return Math.max(0, this.maxProactive - used);
160
+ }
161
+ /** True iff the per-channel proactive budget is exhausted for the rolling window. */
162
+ isRateLimited(channelId) {
163
+ return this.recentCount(channelId) >= this.maxProactive;
164
+ }
165
+ /** Count of proactive sends within the rolling window; prunes expired entries. */
166
+ recentCount(channelId) {
167
+ const arr = this.proactiveTimestamps.get(channelId);
168
+ if (!arr || arr.length === 0)
169
+ return 0;
170
+ const cutoff = this.now() - this.windowMs;
171
+ const fresh = arr.filter(ts => ts >= cutoff);
172
+ if (fresh.length !== arr.length) {
173
+ // Prune expired entries so the map can't grow unbounded.
174
+ if (fresh.length === 0)
175
+ this.proactiveTimestamps.delete(channelId);
176
+ else
177
+ this.proactiveTimestamps.set(channelId, fresh);
178
+ }
179
+ return fresh.length;
180
+ }
181
+ decide(channelId, decision) {
182
+ try {
183
+ this.onDecision?.(decision, channelId);
184
+ }
185
+ catch {
186
+ /* observability is best-effort and must never affect the verdict */
187
+ }
188
+ return decision;
189
+ }
190
+ }
191
+ /**
192
+ * Validate + clamp the LLM's raw JSON into a safe verdict. Returns null when the
193
+ * payload can't be read (→ caller fails to silence). A missing/false `speak` is a
194
+ * VALID parse (it means don't speak), so this only returns null on structural junk.
195
+ */
196
+ function parseAmbientVerdict(raw) {
197
+ if (!raw || typeof raw !== 'string')
198
+ return null;
199
+ const start = raw.indexOf('{');
200
+ const end = raw.lastIndexOf('}');
201
+ if (start === -1 || end === -1 || end < start)
202
+ return null;
203
+ let obj;
204
+ try {
205
+ obj = JSON.parse(raw.slice(start, end + 1));
206
+ }
207
+ catch {
208
+ return null;
209
+ }
210
+ // `speak` must be present and boolean-ish; anything else is unreadable → null,
211
+ // so the caller fails to silence rather than guessing an affirmative.
212
+ let speak;
213
+ if (typeof obj.speak === 'boolean')
214
+ speak = obj.speak;
215
+ else if (obj.speak === 'true')
216
+ speak = true;
217
+ else if (obj.speak === 'false')
218
+ speak = false;
219
+ else
220
+ return null;
221
+ // Missing/invalid confidence is treated as the floor (0) — never an optimistic 1.
222
+ let confidence = typeof obj.confidence === 'number' ? obj.confidence : Number(obj.confidence);
223
+ if (!Number.isFinite(confidence))
224
+ confidence = 0;
225
+ confidence = Math.max(0, Math.min(1, confidence));
226
+ const contribution = typeof obj.contribution === 'string' && obj.contribution.trim()
227
+ ? obj.contribution.trim().slice(0, 500)
228
+ : undefined;
229
+ return { speak, confidence, contribution };
230
+ }
231
+ /**
232
+ * Build the "should I speak?" prompt. It DEFAULTS TO SILENCE and asks the model to
233
+ * return speak:true ONLY when it can name a concrete, meaningful contribution — and
234
+ * never to interrupt a human-to-human exchange without clear value (§5.2 guardrails).
235
+ */
236
+ function buildAmbientPrompt(input) {
237
+ const systemPrompt = [
238
+ 'You are an AI agent present in a shared Slack channel. You were NOT mentioned and',
239
+ 'NO ONE addressed you. Decide whether to volunteer an UNPROMPTED contribution.',
240
+ 'Your strong default is SILENCE. A bot that barges in is worse than a silent one;',
241
+ 'the failure mode is annoyance. Speak ONLY when you can name a concrete, specific,',
242
+ 'genuinely helpful contribution that the people in the channel would welcome —',
243
+ 'never to chime in, agree, restate, or interrupt a human-to-human exchange.',
244
+ 'Return ONLY a compact JSON object, no prose, with exactly these keys:',
245
+ ' "speak": boolean — true ONLY for a clearly worthwhile unprompted contribution.',
246
+ ' "confidence": 0.0-1.0 — your confidence that speaking adds clear value AND is welcome.',
247
+ ' Be honest; when unsure, return a LOW number. Uncertainty means stay silent.',
248
+ ' "contribution": one short sentence naming the concrete contribution (omit/empty if not speaking).',
249
+ 'When in doubt, return {"speak": false, "confidence": 0.0}.',
250
+ 'Never output anything but the JSON object.',
251
+ ].join('\n');
252
+ const userPrompt = [
253
+ input.channelName ? `channel: ${input.channelName}` : 'channel: (unnamed)',
254
+ 'overheard message (you were NOT addressed):',
255
+ '"""',
256
+ (input.text || '').slice(0, 2000),
257
+ '"""',
258
+ ].join('\n');
259
+ return { systemPrompt, userPrompt };
260
+ }
261
+ //# sourceMappingURL=AmbientContributionGate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AmbientContributionGate.js","sourceRoot":"","sources":["../../src/permissions/AmbientContributionGate.ts"],"names":[],"mappings":"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"}
@@ -6,6 +6,7 @@ export * from './types.js';
6
6
  export * from './RolePolicy.js';
7
7
  export * from './IntentClassifier.js';
8
8
  export * from './LlmIntentClassifier.js';
9
+ export * from './AmbientContributionGate.js';
9
10
  export * from './AnomalyScorer.js';
10
11
  export * from './PermissionDecisionLedger.js';
11
12
  export * from './SlackPermissionGate.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/permissions/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,oBAAoB,CAAC;AACnC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,0BAA0B,CAAC;AACzC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,8BAA8B,CAAC;AAC7C,cAAc,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/permissions/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,oBAAoB,CAAC;AACnC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,0BAA0B,CAAC;AACzC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,8BAA8B,CAAC;AAC7C,cAAc,wBAAwB,CAAC"}
@@ -6,6 +6,7 @@ export * from './types.js';
6
6
  export * from './RolePolicy.js';
7
7
  export * from './IntentClassifier.js';
8
8
  export * from './LlmIntentClassifier.js';
9
+ export * from './AmbientContributionGate.js';
9
10
  export * from './AnomalyScorer.js';
10
11
  export * from './PermissionDecisionLedger.js';
11
12
  export * from './SlackPermissionGate.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/permissions/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,oBAAoB,CAAC;AACnC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,0BAA0B,CAAC;AACzC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,8BAA8B,CAAC;AAC7C,cAAc,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/permissions/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,oBAAoB,CAAC;AACnC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,0BAA0B,CAAC;AACzC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,8BAA8B,CAAC;AAC7C,cAAc,wBAAwB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.443",
3
+ "version": "1.3.444",
4
4
  "description": "Coherence infrastructure for self-evolving AI agents — on the Claude Code or Codex subscription you already have.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-06-09T09:35:09.530Z",
5
- "instarVersion": "1.3.443",
4
+ "generatedAt": "2026-06-09T10:00:34.029Z",
5
+ "instarVersion": "1.3.444",
6
6
  "entryCount": 199,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -0,0 +1,24 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ feat(permissions): conservative ambient "should I speak?" gate for Slack (`AmbientContributionGate`) — Phase 2, piece 2. For an UNDIRECTED message in an explicitly opted-in channel, the agent decides whether to proactively contribute, defaulting to **silence**. **FAIL-TO-SILENCE + dark/opt-in.**
9
+
10
+ - Speak=true only when ALL hold: channel opted-in + under a hard per-channel rolling-window rate-limit (default 1 / 30 min) + LLM judges meaningful contribution above a conservative confidence floor (default 0.85). Any failure / uncertainty / malformed LLM output → silent.
11
+ - Wires at the `_handleMessage` mention-only skip; **directed messages (DM/@mention) are untouched**; no config → byte-for-byte today's mention-only behavior. **Zero new Slack Web API calls.**
12
+
13
+ ## What to Tell Your User
14
+
15
+ Nothing changes by default — this ships dark/opt-in. When you turn ambient mode on for a specific channel, the agent can occasionally chime in on messages that weren't addressed to it — but only when it genuinely adds value, at most rarely (a hard rate limit), and never if it's uncertain. It defaults to staying quiet; the worst case is it's too quiet, never too chatty, and it can't be talked into over-speaking by a crafted message. Direct mentions and DMs are unaffected.
16
+
17
+ ## Summary of New Capabilities
18
+
19
+ - **`ambientContribution.enabledChannelIds` (+ `maxProactivePerChannel`, `windowMs`, `minConfidence`)** (opt-in, per-channel) — turns on the conservative ambient "should I speak?" gate for the named channels. Off everywhere by default; operator/internal config; the gate grants no permissions (a proactive turn still passes the permission gate).
20
+
21
+ ## Evidence
22
+
23
+ - 32 tests: 23 gate (dark/opt-in, speak-path, silence-on-low-value, 8 fail-to-silence paths, rate-limit incl. per-channel/zero-cap/window-refill) + 9 wiring (dark default drops with no LLM call, DM/@mention unaffected, ambient+speak processes, ambient+silent drops, LLM-throws drops, rate-limit drops, unauthorized rejected-before-gate). `tsc --noEmit` clean; full lint suite green (incl. notification-flood-burst-invariant); 42 adjacent slack-permission tests green.
24
+ - Side-effects review (`upgrades/side-effects/slack-ambient-gate.md`) + an independent adversarial Phase-5 second-pass on over-speak / fail-open / opt-in-bypass.
@@ -0,0 +1,69 @@
1
+ # Side-Effects Review — Slack ambient "should I speak?" gate
2
+
3
+ **Version / slug:** `slack-ambient-gate`
4
+ **Date:** 2026-06-09
5
+ **Author:** Instar Agent (echo)
6
+ **Second-pass reviewer:** REQUIRED (changes when an undirected message is PROCESSED; LLM reads untrusted content) — independent adversarial review, see Phase 5
7
+
8
+ ## Summary of the change
9
+
10
+ Phase 2, piece 2. `AmbientContributionGate.shouldSpeak()` decides whether the agent PROACTIVELY engages with an UNDIRECTED Slack message in an explicitly opted-in channel. **FAIL-TO-SILENCE:** speak only when ALL of (channel opted-in) + (under a hard per-channel rate-limit) + (LLM judges meaningful contribution above a conservative confidence threshold); ANY failure/uncertainty → silent. Dark/opt-in (no config → no gate attached → byte-for-byte today's mention-only behavior). Files: `src/permissions/AmbientContributionGate.ts` (new), `src/permissions/index.ts`, `src/messaging/slack/types.ts` (dark config block), `src/messaging/slack/SlackAdapter.ts` (wiring at the mention-only-skip), `src/commands/server.ts` (attach only when ≥1 channel opted in).
11
+
12
+ Decision point: whether an undirected message is processed at all (it was always dropped before unless directed).
13
+
14
+ ## Decision-point inventory
15
+
16
+ - `SlackAdapter._handleMessage` mention-only-skip — **modify** — for an undirected message in an ambient-opted-in channel, run the gate; speak=false → the original drop path (unchanged); speak=true → process as a directed message. Directed messages (DM/@mention) never enter this block.
17
+ - `AmbientContributionGate.shouldSpeak` — **add** — fail-to-silence speak/silent decision.
18
+
19
+ ---
20
+
21
+ ## 1. Over-block
22
+
23
+ Not applicable in the harmful sense — the gate's bias IS toward silence (over-block). The "cost" of over-silence is the agent staying quiet when it could have helpfully contributed — the safe, intended direction for a conservative ambient mode.
24
+
25
+ ## 2. Under-block (the real risk = OVER-SPEAK)
26
+
27
+ The danger is the agent speaking when it shouldn't (noise, or engaging with content it wasn't addressed in). Mitigations, ALL required for speak=true: explicit per-channel opt-in; a hard rolling-window rate-limit (conservative default 1 / 30 min / channel); an LLM meaningful-contribution judgment above a conservative confidence floor (default 0.85). Crucially, even a fully prompt-injected LLM "speak:true, confidence:1.0" is still bounded by the opt-in + rate-limit, and a malformed/uncertain verdict fails to silence. A speak=true only routes the message into the SAME downstream handling a directed message gets (including the permission gate) — it does not bypass any permission.
28
+
29
+ ## 3. Level-of-abstraction fit
30
+
31
+ Correct. It slots at the exact mention-only-skip point — the one place undirected messages are decided — as a drop-in gate, not a parallel path. The LLM call mirrors the established `IntelligenceProvider.evaluate` pattern.
32
+
33
+ ## 4. Signal vs authority compliance
34
+
35
+ **Required reference:** docs/signal-vs-authority.md + "no silent degradation." The gate holds authority (it can cause the agent to engage), and it is NOT brittle in the dangerous direction:
36
+ - It is FAIL-TO-SILENCE — the deterministic bounds (opt-in, rate-limit) gate the LLM, and the LLM call is deliberately NOT `gating:true` (a provider-swap would keep the decision alive; here the safe failure is silence, so any error lands in the catch → silent). Every degraded path = silence, never over-speak.
37
+ - It grants NO permission — a proactive turn still passes through the permission gate for anything it then does. So even maximal over-speak cannot widen access.
38
+
39
+ ## 5. Interactions
40
+
41
+ - **Directed handling untouched:** the block is inside `respondMode==='mention-only' && !isDM && !@mention`, so DMs and mentions never reach it (verified at SlackAdapter.ts:907).
42
+ - **Rate-limit budget:** consumed (`recordSpoke`) only AFTER committing to process a proactive turn — no double-spend, no spend-on-silence. Per-channel keyed (no cross-channel budget bleed). In-memory; a restart resets the window, which can only make the agent quieter (safe side).
43
+ - **Ring buffer:** an un-spoken undirected message is still buffered for context (as today) then dropped — no behavior change there.
44
+
45
+ ## 6. External surfaces
46
+
47
+ - **Other agents / install base:** none — dark by default (gate attached only when `ambientContribution.enabledChannelIds` non-empty). Byte-for-byte today's mention-only behavior otherwise.
48
+ - **External systems (Slack):** **ZERO new Slack Web API calls** (verified — the gate only decides whether to PROCESS; it never sends). The LLM provider is the existing IntelligenceRouter.
49
+ - **Untrusted input:** the LLM reads the message text (prompt-injection surface) — bounded by the fail-to-silence + opt-in + rate-limit invariant (focus of the Phase-5 review).
50
+
51
+ ## 7. Rollback cost
52
+
53
+ Trivial. Additive + dark. Revert + patch; default (mention-only) behavior is unchanged on every install. In-memory rate-limit state is disposable.
54
+
55
+ ## Phase 5 — Second-pass review (independent, adversarial — over-speak / fail-open)
56
+
57
+ REQUIRED. An independent reviewer attempted to force over-speak via prompt injection, find a fail-OPEN path, and bypass the opt-in/rate-limit. Verdict appended below.
58
+
59
+ ### Verdict: CONCUR — fail-to-silence holds; no over-speak / bypass / fail-open
60
+
61
+ The independent reviewer traced all six vectors against the live code:
62
+ - **Prompt injection (over-speak):** untrusted text is fenced into the user prompt only; even a message mimicking the exact desired verdict can at most satisfy the LLM condition — it cannot touch the structural bounds (opt-in `Set.has`, double-guarded; per-channel rate-limit). No JSON-injection hole (malformed → null → silence). Ceiling under attack = the chosen conservative bound (1 proactive msg/window in an already-opted-in channel).
63
+ - **Fail-open:** none — every degraded branch (throw/timeout/circuit, empty/non-JSON, missing/`1`/string `speak`, NaN/negative/>1/missing `confidence`, thrown `onDecision` hook) lands on `speak:false`; defaults are silence + confidence 0.
64
+ - **Opt-in/rate-limit bypass:** none — opt-in is first + text-independent; budget consumed only AFTER commit (`recordSpoke` inside `if (ambientSpeak)`); rate-limit checked before the LLM; per-channel keyed (no cross-channel bleed); Socket-Mode redelivery deduped before the gate (no double-spend/double-speak).
65
+ - **Directed regression:** none — the whole block is inside `respondMode==='mention-only' && !isDM && !@mention`; DMs/mentions never enter it (wiring tests confirm 0 LLM calls).
66
+ - **Leak:** none — the prompt carries only the one overheard message (channel name passed undefined); a speak=true only processes content already being buffered.
67
+ - **Dark default:** byte-for-byte today's mention-only drop when unconfigured; an opted-in channel with a null provider still stays silent (`no-intelligence`).
68
+
69
+ The deliberate choice to NOT mark the LLM call `gating:true` is correct + load-bearing (a gating swap would keep the decision alive; here the safe failure is silence, so the error reaches the catch). 32/32 tests pass. **The gate can only ever make the agent quieter.**