instar 1.3.443 → 1.3.445
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/README.md +9 -1
- package/dist/commands/server.d.ts +20 -1
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +98 -30
- package/dist/commands/server.js.map +1 -1
- package/dist/messaging/slack/SlackAdapter.d.ts +17 -0
- package/dist/messaging/slack/SlackAdapter.d.ts.map +1 -1
- package/dist/messaging/slack/SlackAdapter.js +53 -5
- package/dist/messaging/slack/SlackAdapter.js.map +1 -1
- package/dist/messaging/slack/types.d.ts +17 -0
- package/dist/messaging/slack/types.d.ts.map +1 -1
- package/dist/messaging/slack/types.js.map +1 -1
- package/dist/permissions/AmbientContributionGate.d.ts +149 -0
- package/dist/permissions/AmbientContributionGate.d.ts.map +1 -0
- package/dist/permissions/AmbientContributionGate.js +261 -0
- package/dist/permissions/AmbientContributionGate.js.map +1 -0
- package/dist/permissions/index.d.ts +1 -0
- package/dist/permissions/index.d.ts.map +1 -1
- package/dist/permissions/index.js +1 -0
- package/dist/permissions/index.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +2 -2
- package/upgrades/1.3.444.md +24 -0
- package/upgrades/1.3.445.md +99 -0
- package/upgrades/side-effects/fixcommand-gate-nonattention-fallthrough.md +93 -0
- package/upgrades/side-effects/slack-ambient-gate.md +69 -0
|
@@ -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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AAyEH,MAAM,qBAAqB,GAAG,CAAC,CAAC;AAChC,MAAM,iBAAiB,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC,aAAa;AACvD,MAAM,sBAAsB,GAAG,IAAI,CAAC;AAQpC,MAAM,OAAO,uBAAuB;IACjB,eAAe,CAAc;IAC7B,YAAY,CAAS;IACrB,QAAQ,CAAS;IACjB,aAAa,CAAS;IACtB,YAAY,CAAwB;IACpC,SAAS,CAAS;IAClB,UAAU,CAA0D;IACpE,GAAG,CAAe;IAEnC;;;;;;;;;OASG;IACc,mBAAmB,GAA0B,IAAI,GAAG,EAAE,CAAC;IAExE,YAAY,OAAoC,EAAE;QAChD,MAAM,GAAG,GAAG,IAAI,CAAC,MAAM,IAAI,EAAE,CAAC;QAC9B,IAAI,CAAC,eAAe,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,iBAAiB,IAAI,EAAE,CAAC,CAAC;QAC5D,6DAA6D;QAC7D,IAAI,CAAC,YAAY,GAAG,GAAG,CAAC,sBAAsB,IAAI,qBAAqB,CAAC;QACxE,IAAI,CAAC,QAAQ,GAAG,GAAG,CAAC,QAAQ,IAAI,iBAAiB,CAAC;QAClD,IAAI,CAAC,aAAa,GAAG,GAAG,CAAC,aAAa,IAAI,sBAAsB,CAAC;QACjE,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC,YAAY,CAAC;QACtC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC;QACxC,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,UAAU,CAAC;QAClC,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IAC5C,CAAC;IAED,sFAAsF;IACtF,mBAAmB;QACjB,OAAO,IAAI,CAAC,eAAe,CAAC,IAAI,GAAG,CAAC,CAAC;IACvC,CAAC;IAED,gEAAgE;IAChE,gBAAgB,CAAC,SAAiB;QAChC,OAAO,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IAC7C,CAAC;IAED;;;OAGG;IACH,KAAK,CAAC,WAAW,CAAC,KAAuB;QACvC,sEAAsE;QACtE,IAAI,CAAC,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC;YAC/C,OAAO,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,SAAS,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,sBAAsB,EAAE,CAAC,CAAC;QACxF,CAAC;QAED,wEAAwE;QACxE,IAAI,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC;YACxC,OAAO,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,SAAS,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC,CAAC;QAChF,CAAC;QAED,gFAAgF;QAChF,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,CAAC;YACvB,OAAO,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,SAAS,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,CAAC,CAAC;QACnF,CAAC;QAED,IAAI,GAAW,CAAC;QAChB,IAAI,CAAC;YACH,MAAM,EAAE,YAAY,EAAE,UAAU,EAAE,GAAG,kBAAkB,CAAC,KAAK,CAAC,CAAC;YAC/D,GAAG,GAAG,MAAM,IAAI,CAAC,YAAY,CAAC,QAAQ,CAAC,GAAG,YAAY,OAAO,UAAU,EAAE,EAAE;gBACzE,KAAK,EAAE,MAAM;gBACb,WAAW,EAAE,CAAC;gBACd,SAAS,EAAE,GAAG;gBACd,SAAS,EAAE,IAAI,CAAC,SAAS;gBACzB,WAAW,EAAE;oBACX,SAAS,EAAE,yBAAyB;oBACpC,QAAQ,EAAE,MAAM;oBAChB,2EAA2E;oBAC3E,4EAA4E;oBAC5E,4EAA4E;oBAC5E,uEAAuE;iBACxE;aACF,CAAC,CAAC;QACL,CAAC;QAAC,MAAM,CAAC;YACP,8EAA8E;YAC9E,OAAO,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,SAAS,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC,CAAC;QAC7E,CAAC;QAED,MAAM,MAAM,GAAG,mBAAmB,CAAC,GAAG,CAAC,CAAC;QACxC,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,0DAA0D;YAC1D,OAAO,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,SAAS,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,CAAC,CAAC;QACnF,CAAC;QAED,qCAAqC;QACrC,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;YAClB,OAAO,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,SAAS,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC,CAAC;QAChF,CAAC;QAED,8EAA8E;QAC9E,IAAI,MAAM,CAAC,UAAU,GAAG,IAAI,CAAC,aAAa,IAAI,CAAC,MAAM,CAAC,YAAY,EAAE,CAAC;YACnE,OAAO,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,SAAS,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,CAAC,YAAY,EAAE,CAAC,CAAC;QAC/G,CAAC;QAED,+CAA+C;QAC/C,OAAO,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,SAAS,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,YAAY,EAAE,CAAC,CAAC;IACrG,CAAC;IAED;;;;;OAKG;IACH,WAAW,CAAC,SAAiB;QAC3B,MAAM,GAAG,GAAG,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;QAC1D,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;QACrB,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;IAC/C,CAAC;IAED,wFAAwF;IACxF,eAAe,CAAC,SAAiB;QAC/B,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,CAAC,SAAS,CAAC,CAAC;QACzC,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAC/C,CAAC;IAED,qFAAqF;IAC7E,aAAa,CAAC,SAAiB;QACrC,OAAO,IAAI,CAAC,WAAW,CAAC,SAAS,CAAC,IAAI,IAAI,CAAC,YAAY,CAAC;IAC1D,CAAC;IAED,kFAAkF;IAC1E,WAAW,CAAC,SAAiB;QACnC,MAAM,GAAG,GAAG,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QACpD,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,CAAC,CAAC;QACvC,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,QAAQ,CAAC;QAC1C,MAAM,KAAK,GAAG,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,IAAI,MAAM,CAAC,CAAC;QAC7C,IAAI,KAAK,CAAC,MAAM,KAAK,GAAG,CAAC,MAAM,EAAE,CAAC;YAChC,yDAAyD;YACzD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;gBAAE,IAAI,CAAC,mBAAmB,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;;gBAC9D,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;QACtD,CAAC;QACD,OAAO,KAAK,CAAC,MAAM,CAAC;IACtB,CAAC;IAEO,MAAM,CAAC,SAAiB,EAAE,QAAyB;QACzD,IAAI,CAAC;YACH,IAAI,CAAC,UAAU,EAAE,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC;QACzC,CAAC;QAAC,MAAM,CAAC;YACP,oEAAoE;QACtE,CAAC;QACD,OAAO,QAAQ,CAAC;IAClB,CAAC;CACF;AAED;;;;GAIG;AACH,SAAS,mBAAmB,CAC1B,GAAW;IAEX,IAAI,CAAC,GAAG,IAAI,OAAO,GAAG,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAEjD,MAAM,KAAK,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAC/B,MAAM,GAAG,GAAG,GAAG,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;IACjC,IAAI,KAAK,KAAK,CAAC,CAAC,IAAI,GAAG,KAAK,CAAC,CAAC,IAAI,GAAG,GAAG,KAAK;QAAE,OAAO,IAAI,CAAC;IAE3D,IAAI,GAAsB,CAAC;IAC3B,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,EAAE,GAAG,GAAG,CAAC,CAAC,CAAsB,CAAC;IACnE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;IAED,+EAA+E;IAC/E,sEAAsE;IACtE,IAAI,KAAc,CAAC;IACnB,IAAI,OAAO,GAAG,CAAC,KAAK,KAAK,SAAS;QAAE,KAAK,GAAG,GAAG,CAAC,KAAK,CAAC;SACjD,IAAI,GAAG,CAAC,KAAK,KAAK,MAAM;QAAE,KAAK,GAAG,IAAI,CAAC;SACvC,IAAI,GAAG,CAAC,KAAK,KAAK,OAAO;QAAE,KAAK,GAAG,KAAK,CAAC;;QACzC,OAAO,IAAI,CAAC;IAEjB,kFAAkF;IAClF,IAAI,UAAU,GAAG,OAAO,GAAG,CAAC,UAAU,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;IAC9F,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC;QAAE,UAAU,GAAG,CAAC,CAAC;IACjD,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC;IAElD,MAAM,YAAY,GAChB,OAAO,GAAG,CAAC,YAAY,KAAK,QAAQ,IAAI,GAAG,CAAC,YAAY,CAAC,IAAI,EAAE;QAC7D,CAAC,CAAC,GAAG,CAAC,YAAY,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC;QACvC,CAAC,CAAC,SAAS,CAAC;IAEhB,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,CAAC;AAC7C,CAAC;AAED;;;;GAIG;AACH,SAAS,kBAAkB,CAAC,KAAuB;IACjD,MAAM,YAAY,GAAG;QACnB,mFAAmF;QACnF,+EAA+E;QAC/E,kFAAkF;QAClF,mFAAmF;QACnF,+EAA+E;QAC/E,4EAA4E;QAC5E,uEAAuE;QACvE,yFAAyF;QACzF,4FAA4F;QAC5F,+FAA+F;QAC/F,qGAAqG;QACrG,4DAA4D;QAC5D,4CAA4C;KAC7C,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAEb,MAAM,UAAU,GAAG;QACjB,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,YAAY,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,oBAAoB;QAC1E,6CAA6C;QAC7C,KAAK;QACL,CAAC,KAAK,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC;QACjC,KAAK;KACN,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAEb,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,CAAC;AACtC,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.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,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./builtin-manifest.schema.json",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
|
-
"generatedAt": "2026-06-
|
|
5
|
-
"instarVersion": "1.3.
|
|
4
|
+
"generatedAt": "2026-06-09T17:12:42.642Z",
|
|
5
|
+
"instarVersion": "1.3.445",
|
|
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,99 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
Scopes the emergency "fix command" gate to the **Agent Attention topic** so it can no longer swallow ordinary messages in other topics. The gate in `wireTelegramRouting` (`src/commands/server.ts`) used to intercept any message starting with `restart`, `fix `, or `clean ` in **every** topic, hand it to `handleFixCommand`, and `return` — but `handleFixCommand` only does anything in the Agent Attention topic and returns `false` everywhere else. So in a normal topic the message was bounced back with *"I didn't recognize that command. Available fix commands: …"* (a list that even advertised "restart sessions" as valid) **and never routed to the session**. A user typing "restart sessions" to revive a stuck session in that session's own topic was hit by exactly this: the gate ate the message. The decision is now a pure, unit-tested helper `shouldInterceptFixCommand(text, topicId, attentionTopicId)` that returns true only inside the attention topic; `wireTelegramRouting` resolves the attention topic via a late-bound `getAttentionTopicId` and both call sites pass it. Outside the attention topic the message falls through to normal session routing — including plain phrases like "restart the build" or "fix the login page" that were silently eaten before.
|
|
9
|
+
|
|
10
|
+
## What to Tell Your User
|
|
11
|
+
|
|
12
|
+
If you ever messaged a session something starting with "restart", "fix", or "clean" — including trying to revive a stuck session by typing "restart sessions" — and got a confusing *"I didn't recognize that command"* reply (with a list that literally showed the command you typed), that message was being intercepted and never reached the session. That's fixed: those messages now go through to the session like any other. The "fix command" shortcuts (restart, fix auth, clean processes, …) still work — they just only apply in the Agent Attention topic, which is where I post the notifications you tap to resolve.
|
|
13
|
+
|
|
14
|
+
## Summary of New Capabilities
|
|
15
|
+
|
|
16
|
+
| Capability | How to Use |
|
|
17
|
+
|-----------|-----------|
|
|
18
|
+
| Fix-command shortcuts scoped to the Agent Attention topic | unchanged — tap/reply in the Attention topic; everywhere else your message reaches the session |
|
|
19
|
+
| Ordinary "restart/fix/clean …" messages reach the session | just send them normally in any session topic |
|
|
20
|
+
|
|
21
|
+
## Evidence
|
|
22
|
+
|
|
23
|
+
Reproduction (live, 2026-06-09, topic 21624 "initiatives and maturation check-ins"): the user typed `restart sessions` to unstick a session and got *"I didn't recognize that command. Available fix commands: … • restart sessions — Restart stuck sessions"* — the gate swallowed the message (no `restart sessions` ever appeared in the session's tmux pane) while listing the exact command as valid. Root cause traced in code: the gate's `isFixCommand` verb test ran in all topics and `return`ed after `handleFixCommand` (which guards on `topicId === agent-attention-topic` and returns `false` otherwise).
|
|
24
|
+
|
|
25
|
+
After the fix: new unit suite `tests/unit/fix-command-routing.test.ts` (17 tests) pins both sides of the boundary — in the attention topic every fix verb intercepts (and verb-lookalikes "fixture"/"cleanup" do not), while in a non-attention topic "restart sessions", "restart the build", "fix the login page", "clean up this function" all return `false` (fall through to the session); a null/undefined attention topic never intercepts. `tsc --noEmit` clean; the full set of unit tests importing `commands/server` (38 files, 364 tests) green.
|
|
26
|
+
|
|
27
|
+
## The problem
|
|
28
|
+
|
|
29
|
+
You sent a session a message to wake it up — "restart sessions" — and instead
|
|
30
|
+
of doing anything, I replied *"I didn't recognize that command"* and showed you
|
|
31
|
+
a list of commands that literally included "restart sessions." That's
|
|
32
|
+
maddening: I rejected the exact thing I told you to type. Worse, your message
|
|
33
|
+
never actually reached the session at all.
|
|
34
|
+
|
|
35
|
+
Here's what was really happening under the hood.
|
|
36
|
+
|
|
37
|
+
I have a set of emergency "fix command" shortcuts — short things you can type
|
|
38
|
+
like "restart", "fix auth", "clean processes" — that I handle directly without
|
|
39
|
+
spinning up a whole AI session. They exist for one specific place: the **Agent
|
|
40
|
+
Attention topic**, where I post little "something needs fixing, tap to resolve"
|
|
41
|
+
notifications. In that topic, typing "restart" should just work.
|
|
42
|
+
|
|
43
|
+
The bug: the code that *catches* those shortcut words was running in **every
|
|
44
|
+
topic**, not just the Attention topic. So any message you sent that happened to
|
|
45
|
+
start with "restart", "fix ", or "clean " got grabbed by the shortcut-catcher
|
|
46
|
+
first. The catcher then asked the real handler to run it — but the real handler
|
|
47
|
+
checks "am I in the Attention topic?" and, if not, quietly says "not mine" and
|
|
48
|
+
does nothing. At that point the catcher gave up with the confusing "I didn't
|
|
49
|
+
recognize that command" message **and threw your message away** — it never got
|
|
50
|
+
passed along to the session you were actually talking to.
|
|
51
|
+
|
|
52
|
+
So two everyday things were broken:
|
|
53
|
+
1. Trying to revive a stuck session by typing "restart sessions" in that
|
|
54
|
+
session's own topic — eaten, with a misleading reply.
|
|
55
|
+
2. Just talking normally — "restart the build", "fix the login page", "clean up
|
|
56
|
+
this function" — anything that started with one of those words got silently
|
|
57
|
+
swallowed instead of reaching the session.
|
|
58
|
+
|
|
59
|
+
## What already exists
|
|
60
|
+
|
|
61
|
+
- The fix-command shortcuts ("restart", "fix auth", etc.) and the handler that
|
|
62
|
+
runs them. These were already correctly limited to the Attention topic *on
|
|
63
|
+
the execution side* — the handler refuses to act anywhere else.
|
|
64
|
+
- The normal path that routes a message to its session. That path was always
|
|
65
|
+
there; the shortcut-catcher was just jumping in front of it and stopping it.
|
|
66
|
+
|
|
67
|
+
## What's new
|
|
68
|
+
|
|
69
|
+
One small, surgical change: the shortcut-catcher now checks **which topic it's
|
|
70
|
+
in before grabbing the message.** It only intercepts inside the Agent Attention
|
|
71
|
+
topic. Everywhere else, the message flows straight through to the session like
|
|
72
|
+
any other message.
|
|
73
|
+
|
|
74
|
+
The check is a tiny pure function, `shouldInterceptFixCommand(text, topicId,
|
|
75
|
+
attentionTopicId)`, that returns "yes, intercept" only when the message is in
|
|
76
|
+
the Attention topic AND looks like a fix command. It's covered by 17 unit tests
|
|
77
|
+
that nail down both sides: in the Attention topic the shortcuts still fire (and
|
|
78
|
+
lookalike words like "fixture" or "cleanup" correctly don't); in any other
|
|
79
|
+
topic, "restart sessions" and friends fall through to the session.
|
|
80
|
+
|
|
81
|
+
## The safeguards in plain terms
|
|
82
|
+
|
|
83
|
+
- **Nothing is removed.** The fix-command shortcuts still work exactly as
|
|
84
|
+
before — they were always Attention-topic-only on the execution side; now the
|
|
85
|
+
catching side agrees. So no real capability is lost.
|
|
86
|
+
- **Fail-safe default.** If I don't yet know which topic is the Attention topic
|
|
87
|
+
(e.g. very early at startup), the catcher simply doesn't intercept — messages
|
|
88
|
+
go to the session. That's the safe direction: route, don't swallow.
|
|
89
|
+
- **It only makes the gate *less* aggressive.** This change can only let more
|
|
90
|
+
messages through to sessions; it can never block a message that wasn't already
|
|
91
|
+
being blocked. That's why the risk is low.
|
|
92
|
+
|
|
93
|
+
## What you need to decide
|
|
94
|
+
|
|
95
|
+
Nothing complicated. This is a bug fix that makes your messages reliably reach
|
|
96
|
+
your sessions. The only judgment call is whether to ship it as a normal patch
|
|
97
|
+
(yes) — there's no new setting to configure and no behavior you have to opt
|
|
98
|
+
into. After it deploys, "restart sessions" (and ordinary chat starting with
|
|
99
|
+
restart/fix/clean) will reach the session instead of being eaten.
|