@segmentstream/agent 0.7.2 → 0.8.0

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 CHANGED
@@ -82,7 +82,7 @@ Both entry points export the same handle: `@segmentstream/agent` and
82
82
  | Field | Default | Meaning |
83
83
  | ---------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
84
84
  | `channels.telegram.connection` | none | The Connect key whose credential is the bot token. Without `channels.telegram`, no Telegram. |
85
- | `channels.telegram.policy` | `"smart"` | Classifies unaddressed group messages and captionless uploads; legacy `mention` is an alias for `smart`; tags are optional |
85
+ | `channels.telegram.policy` | `"smart"` | Classifies unaddressed group messages and captionless uploads; `mention` observes unaddressed messages without decoding or inference |
86
86
  | `channels.telegram.linkPreviews` | `external` | Suppress own-workspace previews and preview the first external link; opt into `all` for native previews or `none` to disable them. Caller options take precedence. |
87
87
  | `channels.telegram.additionalOwnOrigins` | `[]` | Additional exact HTTP(S) workspace origins, including local ports; no paths or credentials. |
88
88
  | `models.stt` | `"spacexai/grok-stt"` | The AI Gateway transcription model for voice messages; `false` leaves them untranscribed |
@@ -94,6 +94,99 @@ An unknown field or an invalid value fails the mount with `SegmentStreamAgentCon
94
94
  each path. `manifestOf(config)` returns the normalized configuration, defaults applied, for the
95
95
  platform's deployment manifest.
96
96
 
97
+ ## Channel admission and first contact
98
+
99
+ The platform and SDK provide admission, identity resolution, onboarding execution, notice tracking
100
+ and durable limits. Workspace apps own channel policy, member grants, onboarding actions, notice
101
+ content and lower limit values. No channel defaults to open, and no notice or limit reply text
102
+ ships as a default. Existing invited-channel pairing and refusal replies remain unchanged.
103
+
104
+ Admission belongs in the ontology's `signIn.channels`; each unlisted provider stays invited,
105
+ independently of email `signIn.policy`. Opening a channel is an explicit workspace opt-in.
106
+ For an intentionally open provider, declare safe named `member.permissions`. Wildcards and
107
+ generated create/update/delete grants are rejected. Shared named reads need review; move staff
108
+ reads into a named role. Actions handling a member's own records enforce ownership with
109
+ `principal.id`. Groups never register users. Closing a channel stops new registrations;
110
+ block or deactivate an existing member to stop their access.
111
+
112
+ Configure app-owned text and lower limits in `agent/segmentstream.json`. These placeholders
113
+ represent text authored by the workspace, not SDK defaults:
114
+
115
+ ```json
116
+ {
117
+ "firstContact": {
118
+ "version": "1",
119
+ "notice": {
120
+ "en": "<workspace-authored first-contact notice>",
121
+ "ru": "<workspace-authored translated notice>"
122
+ }
123
+ },
124
+ "limits": {
125
+ "perSender": { "turnsPerMinute": 4, "turnsPerDay": 40 },
126
+ "newUsersPerHour": 20,
127
+ "workspaceTurnsPerDay": 2000
128
+ },
129
+ "replies": {
130
+ "perSenderLimited": { "en": "<workspace-authored sender limit reply>" },
131
+ "workspaceLimited": { "en": "<workspace-authored workspace limit reply>" },
132
+ "registrationsLimited": { "en": "<workspace-authored registration limit reply>" }
133
+ }
134
+ }
135
+ ```
136
+
137
+ All three sections are optional. If present, `firstContact` requires a nonblank version of at most
138
+ 200 characters, without surrounding whitespace, and a nonempty locale-to-text `notice` map.
139
+ Text values must be nonblank and at most 4000 characters; locale keys must be valid language tags.
140
+ Unknown settings, including the superseded `publicUsers` field, fail validation.
141
+ The sender's language code chooses the exact locale, then its base language, then `en`,
142
+ then the first entry. The same selection applies to configured replies. `replies.limited` is an
143
+ optional fallback for any missing scope-specific limit reply. Without either reply, limits silently
144
+ stop processing. `groupPairingRequired` and `refused` may override existing pairing/refusal copy.
145
+
146
+ Omitted limit fields use platform ceilings: 6 turns/minute and 80/day per sender, 30 new users/hour,
147
+ and 3000 workspace turns/day. Overrides must be positive integers at or below each ceiling.
148
+ The trusted runtime submits these effective limits; the ontology clamps and persists them for other
149
+ admission paths. Only self-registered users with exactly the member role consume sender/workspace
150
+ turn quotas, keyed by user id across identities. Paired-only users and holders of named roles
151
+ remain exempt. Every registration shares the hourly new-user cap. These settings grant no access.
152
+
153
+ Admission runs after cheap edit/service gates, before decoding, then before jev and chat.
154
+ Private messages with content and directly addressed group messages resolve with `turn: true`.
155
+ Other group messages first resolve with `turn: false`, including replies to the bot that address
156
+ another recipient. Mention-policy and other-recipient messages observe without counting,
157
+ downloading or decoding. A recipient-only reply to a recent pending question remains eligible
158
+ as answer data. Empty events remain uncounted even with a pending question.
159
+ Eligible senders obtain counted admission before transcription, attachment work, smart-routing
160
+ inference or pending-answer dispatch. Smart decisions
161
+ consume a turn even if they choose observation, and reuse it if they reply. Each message requests
162
+ counted admission at most once. No-op edits never resolve; caption-only edits reuse decoded
163
+ evidence without downloading the same attachments again. Deferred-card presses use GET lookup
164
+ and act-as authorization, never resolve or quotas: their initiating turn was already counted.
165
+ Limited private inbound events and replies retain the member's archive grant through an uncounted
166
+ binding lookup before the first persistence.
167
+
168
+ Resolver statuses are `existing`, `created`, `pairing_required`, `refused` (inactive/blocked)
169
+ and `limited`. A limit reply requires the server's `notify` flag and an addressed message or
170
+ chosen reply. Empty events get no turn or pairing reply. A configured due notice is sent only on
171
+ a private eligible turn, before processing; resolve carries its version and selected-text SHA-256,
172
+ and acknowledgement records the same version/hash after delivery. Version or selected-text changes
173
+ request delivery again. Failures retain diagnostics and retry on the next private turn.
174
+ Without a configured notice, the SDK sends and acknowledges nothing. Ontology check warns about
175
+ missing notice/limits when agent config is supplied; its CLI reports when that check is skipped.
176
+
177
+ An app may declare `signIn.onNewUser` as an idempotent member-runnable action. The ontology executes
178
+ it before returning an accepted eligible resolve, retries pending failures, and never delegates it
179
+ to the model. Handlers receive read-only `principal.identities[{provider, subject, username?, verified}]`,
180
+ `createdVia` and `authenticatedVia`. The SDK forwards verified provider provenance with the
181
+ member's act-as header. No identity facts contain credentials.
182
+
183
+ ### Release order
184
+
185
+ Deploy backend-next's strict validator before publishing agent SDK 0.8.0. Upgrade the workspace
186
+ ontology to the v2 resolver/notice contract before its agent. A resolve-route HTTP 404 falls back
187
+ to the unchanged GET lookup and invited pairing; other failures stop admission. Older agents
188
+ continue using GET lookup. Starter templates and pins remain unchanged in this stage.
189
+
97
190
  ## What the extension contributes
98
191
 
99
192
  | Name | Slot | What it does |
@@ -135,7 +228,7 @@ share a group chat's session never share a client.
135
228
  preview never answers a real chat. Without `channels.telegram` it answers 404.
136
229
  - **Pairing.** Bots and channel posts are archived without starting turns. The sender's Telegram
137
230
  account is checked on every message, so revocation takes effect immediately. Unlinked group
138
- speech, including edits and voice transcripts, remains context and never starts a turn or gets
231
+ speech and attachment metadata remain raw context, including edits; no unlinked voice is decoded or gets
139
232
  a reply. An unknown private sender gets a neutral connection button to `/agent/connect?code=…`,
140
233
  valid for ten minutes; revoked or inactive private senders get a neutral refusal marker.
141
234
  - **Who the agent is.** A paired sender's turn runs with the principal id `telegram:<user>`
@@ -144,7 +237,7 @@ share a group chat's session never share a client.
144
237
  - **Routing.** Private chats and messages explicitly addressed to the bot retain direct admission.
145
238
  Other group messages, including captionless attachments, go through one `decide` question with
146
239
  the agent's purpose, recent conversation, open questions and confirmation state. The legacy
147
- `mention` policy is an alias for `smart`; tags are optional. Attachments never force a turn.
240
+ `mention` policy observes unaddressed messages without paid work. Attachments never force a turn.
148
241
  Human reply edges and other recipients are untrusted context for inference. Failed decisions
149
242
  observe. Unanswered messages join the bounded backlog; answered messages remain in recent
150
243
  shared context without rejoining that backlog on retries. Decision events retain the reason,
@@ -12,6 +12,7 @@ var telegram_default = segmentstreamTelegram({
12
12
  models: () => segmentstream.config.models,
13
13
  confirmationMode: () => segmentstream.config.confirmations.mode,
14
14
  sessions: () => segmentstream.config.sessions,
15
+ admission: () => segmentstream.config,
15
16
  decide: (options) => decide(options)
16
17
  });
17
18
  export { telegram_default as default };
@@ -1,8 +1,8 @@
1
1
  import { type TelegramLinkPreviews } from "@segmentstream-cloud/ontology/telegram";
2
2
  export declare const TELEGRAM_POLICIES: readonly ["mention", "smart"];
3
3
  /**
4
- * Group messages use smart routing without requiring tags. `mention` remains a legacy alias
5
- * for stored configurations; it does not disable untagged requests or follow-ups.
4
+ * Smart routing classifies unaddressed group messages. Mention policy observes them
5
+ * without decoding or inference; direct requests and pending-question replies still run.
6
6
  */
7
7
  export type TelegramPolicy = (typeof TELEGRAM_POLICIES)[number];
8
8
  /** The speech-to-text model voice messages are transcribed with, in the AI Gateway catalog. */
@@ -12,11 +12,46 @@ export declare const DEFAULT_IMAGE_DESCRIPTION_MODEL = "google/gemini-2.5-flash-
12
12
  export declare const DEFAULT_ATTACHMENTS_MODEL = "google/gemini-2.5-flash-lite";
13
13
  /** Pinned platform routing model; independent of eve's own default. */
14
14
  export declare const DEFAULT_DECISION_MODEL = "typesafe-ai/jev-1.14.0";
15
+ /** Platform ceilings; workspace configuration may only lower them. */
16
+ export declare const DEFAULT_ADMISSION_LIMITS: {
17
+ readonly perSender: {
18
+ readonly turnsPerMinute: 6;
19
+ readonly turnsPerDay: 80;
20
+ };
21
+ readonly newUsersPerHour: 30;
22
+ readonly workspaceTurnsPerDay: 3000;
23
+ };
24
+ export interface AdmissionLimits {
25
+ readonly perSender: {
26
+ readonly turnsPerMinute: number;
27
+ readonly turnsPerDay: number;
28
+ };
29
+ readonly newUsersPerHour: number;
30
+ readonly workspaceTurnsPerDay: number;
31
+ }
32
+ export type AdmissionLimitsInput = Partial<Omit<AdmissionLimits, "perSender">> & {
33
+ readonly perSender?: Partial<AdmissionLimits["perSender"]>;
34
+ };
35
+ export type LocalizedText = Readonly<Record<string, string>>;
36
+ export interface FirstContactSettings {
37
+ readonly version: string;
38
+ readonly notice: LocalizedText;
39
+ }
40
+ export declare const ADMISSION_REPLY_KEYS: readonly ["limited", "perSenderLimited", "workspaceLimited", "registrationsLimited", "groupPairingRequired", "refused"];
41
+ export type AdmissionReplies = Partial<Record<(typeof ADMISSION_REPLY_KEYS)[number], LocalizedText>>;
42
+ export interface AdmissionSettings {
43
+ readonly firstContact: FirstContactSettings | null;
44
+ readonly limits: AdmissionLimits;
45
+ readonly replies: AdmissionReplies;
46
+ }
15
47
  /**
16
48
  * `agent/segmentstream.json` as authored. A JSON import widens string literals to `string` and
17
49
  * `false` to `boolean`, so the accepted types are wider than the values validation allows.
18
50
  */
19
51
  export interface SegmentStreamAgentConfigInput {
52
+ readonly firstContact?: FirstContactSettings;
53
+ readonly limits?: AdmissionLimitsInput;
54
+ readonly replies?: AdmissionReplies;
20
55
  readonly channels?: {
21
56
  readonly telegram?: {
22
57
  /** The Connect key whose credential is the bot token, such as `telegram`. */
@@ -104,7 +139,7 @@ export interface SessionSettings {
104
139
  readonly idleResetHours: number | false;
105
140
  }
106
141
  /** The validated configuration with defaults applied. It stays JSON-serializable. */
107
- export interface SegmentStreamAgentConfig {
142
+ export interface SegmentStreamAgentConfig extends AdmissionSettings {
108
143
  readonly channels: {
109
144
  readonly telegram: TelegramSettings | null;
110
145
  };
@@ -11,6 +11,22 @@ const DEFAULT_STT_MODEL = "spacexai/grok-stt";
11
11
  const DEFAULT_IMAGE_DESCRIPTION_MODEL = "google/gemini-2.5-flash-lite";
12
12
  const DEFAULT_ATTACHMENTS_MODEL = DEFAULT_IMAGE_DESCRIPTION_MODEL;
13
13
  const DEFAULT_DECISION_MODEL = "typesafe-ai/jev-1.14.0";
14
+ const DEFAULT_ADMISSION_LIMITS = {
15
+ perSender: {
16
+ turnsPerMinute: 6,
17
+ turnsPerDay: 80
18
+ },
19
+ newUsersPerHour: 30,
20
+ workspaceTurnsPerDay: 3e3
21
+ };
22
+ const ADMISSION_REPLY_KEYS = [
23
+ "limited",
24
+ "perSenderLimited",
25
+ "workspaceLimited",
26
+ "registrationsLimited",
27
+ "groupPairingRequired",
28
+ "refused"
29
+ ];
14
30
  const SESSION_SCOPES = ["chat", "thread"];
15
31
  var SegmentStreamAgentConfigError = class extends Error {
16
32
  issues;
@@ -197,6 +213,103 @@ function sessionSettings(value, issues) {
197
213
  idleResetHours: typeof idleResetHours === "number" && Number.isFinite(idleResetHours) && idleResetHours > 0 ? idleResetHours : false
198
214
  };
199
215
  }
216
+ function localizedText(value, path, issues) {
217
+ if (!isRecord(value) || Object.keys(value).length === 0) {
218
+ issues.push({
219
+ message: "must be a non-empty locale-to-text object",
220
+ path
221
+ });
222
+ return {};
223
+ }
224
+ for (const [locale, text] of Object.entries(value)) {
225
+ try {
226
+ if (Intl.getCanonicalLocales(locale).length !== 1) throw new RangeError("empty locale");
227
+ } catch {
228
+ issues.push({
229
+ message: "must be a language tag such as en or en-US",
230
+ path: [...path, locale]
231
+ });
232
+ }
233
+ if (typeof text !== "string" || text.trim() === "" || text.length > 4e3) issues.push({
234
+ message: "must be non-empty text of at most 4000 characters",
235
+ path: [...path, locale]
236
+ });
237
+ }
238
+ return value;
239
+ }
240
+ function firstContactSettings(value, issues) {
241
+ const path = ["firstContact"];
242
+ const settings = section({
243
+ value,
244
+ path,
245
+ allowed: ["version", "notice"],
246
+ issues
247
+ });
248
+ if (settings === void 0) return null;
249
+ const version = settings.version;
250
+ if (typeof version !== "string" || version.trim() === "" || version !== version.trim() || version.length > 200) issues.push({
251
+ message: "must be non-empty text of at most 200 characters without surrounding whitespace",
252
+ path: [...path, "version"]
253
+ });
254
+ const notice = localizedText(settings.notice, [...path, "notice"], issues);
255
+ return {
256
+ version: typeof version === "string" ? version : "",
257
+ notice
258
+ };
259
+ }
260
+ function admissionLimits(value, issues) {
261
+ const path = ["limits"];
262
+ const limits = section({
263
+ value,
264
+ path,
265
+ allowed: Object.keys(DEFAULT_ADMISSION_LIMITS),
266
+ issues
267
+ });
268
+ const perSender = section({
269
+ value: limits?.perSender,
270
+ path: [...path, "perSender"],
271
+ allowed: Object.keys(DEFAULT_ADMISSION_LIMITS.perSender),
272
+ issues
273
+ });
274
+ function limit(value, ceiling, path) {
275
+ if (value === void 0) return ceiling;
276
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > ceiling) {
277
+ issues.push({
278
+ message: "must be an integer between 1 and " + ceiling,
279
+ path
280
+ });
281
+ return ceiling;
282
+ }
283
+ return value;
284
+ }
285
+ return {
286
+ perSender: {
287
+ turnsPerMinute: limit(perSender?.turnsPerMinute, DEFAULT_ADMISSION_LIMITS.perSender.turnsPerMinute, [
288
+ ...path,
289
+ "perSender",
290
+ "turnsPerMinute"
291
+ ]),
292
+ turnsPerDay: limit(perSender?.turnsPerDay, DEFAULT_ADMISSION_LIMITS.perSender.turnsPerDay, [
293
+ ...path,
294
+ "perSender",
295
+ "turnsPerDay"
296
+ ])
297
+ },
298
+ newUsersPerHour: limit(limits?.newUsersPerHour, DEFAULT_ADMISSION_LIMITS.newUsersPerHour, [...path, "newUsersPerHour"]),
299
+ workspaceTurnsPerDay: limit(limits?.workspaceTurnsPerDay, DEFAULT_ADMISSION_LIMITS.workspaceTurnsPerDay, [...path, "workspaceTurnsPerDay"])
300
+ };
301
+ }
302
+ function admissionReplies(value, issues) {
303
+ const replies = section({
304
+ value,
305
+ path: ["replies"],
306
+ allowed: ADMISSION_REPLY_KEYS,
307
+ issues
308
+ });
309
+ const configured = {};
310
+ for (const key of ADMISSION_REPLY_KEYS) if (replies?.[key] !== void 0) configured[key] = localizedText(replies[key], ["replies", key], issues);
311
+ return configured;
312
+ }
200
313
  function validateConfig(input) {
201
314
  const issues = [];
202
315
  const root = section({
@@ -207,7 +320,10 @@ function validateConfig(input) {
207
320
  "models",
208
321
  "ontology",
209
322
  "sessions",
210
- "confirmations"
323
+ "confirmations",
324
+ "firstContact",
325
+ "limits",
326
+ "replies"
211
327
  ],
212
328
  issues
213
329
  });
@@ -273,6 +389,9 @@ function validateConfig(input) {
273
389
  });
274
390
  const connection = ontology?.connection ?? true;
275
391
  const sessions = sessionSettings(root?.sessions, issues);
392
+ const firstContact = firstContactSettings(root?.firstContact, issues);
393
+ const limits = admissionLimits(root?.limits, issues);
394
+ const replies = admissionReplies(root?.replies, issues);
276
395
  if (typeof connection !== "boolean") issues.push({
277
396
  message: "must be true or false",
278
397
  path: ["ontology", "connection"]
@@ -289,7 +408,10 @@ function validateConfig(input) {
289
408
  },
290
409
  ontology: { connection: connection === true },
291
410
  confirmations: { mode: mode === "held" ? "held" : "deferred" },
292
- sessions
411
+ sessions,
412
+ firstContact,
413
+ limits,
414
+ replies
293
415
  } };
294
416
  }
295
417
  const configSchema = { "~standard": {
@@ -303,4 +425,4 @@ function manifestOf(config) {
303
425
  if (validation.issues !== void 0) throw new SegmentStreamAgentConfigError(validation.issues);
304
426
  return validation.value;
305
427
  }
306
- export { DEFAULT_ATTACHMENTS_MODEL, DEFAULT_DECISION_MODEL, DEFAULT_IMAGE_DESCRIPTION_MODEL, DEFAULT_STT_MODEL, SESSION_SCOPES, SegmentStreamAgentConfigError, TELEGRAM_POLICIES, configSchema, manifestOf, validateConfig };
428
+ export { ADMISSION_REPLY_KEYS, DEFAULT_ADMISSION_LIMITS, DEFAULT_ATTACHMENTS_MODEL, DEFAULT_DECISION_MODEL, DEFAULT_IMAGE_DESCRIPTION_MODEL, DEFAULT_STT_MODEL, SESSION_SCOPES, SegmentStreamAgentConfigError, TELEGRAM_POLICIES, configSchema, manifestOf, validateConfig };
@@ -15,5 +15,5 @@ export declare const segmentstream: import("eve/extension").ExtensionHandle<{
15
15
  manifestOf: typeof manifestOf;
16
16
  };
17
17
  export default segmentstream;
18
- export { DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SegmentStreamAgentConfigError, manifestOf, type ChatModelChoice, type SegmentStreamAgentConfig, type SegmentStreamAgentConfigInput, type SessionScope, type SessionSettings, type TelegramPolicy, type TelegramSettings, } from "./config.js";
18
+ export { DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, DEFAULT_ADMISSION_LIMITS, SegmentStreamAgentConfigError, manifestOf, type ChatModelChoice, type AdmissionLimits, type AdmissionLimitsInput, type AdmissionSettings, type AdmissionReplies, type FirstContactSettings, type LocalizedText, type SegmentStreamAgentConfig, type SegmentStreamAgentConfigInput, type SessionScope, type SessionSettings, type TelegramPolicy, type TelegramSettings, } from "./config.js";
19
19
  export { chooseModel, model, type DynamicModel, type ModelChoice } from "./model.js";
@@ -4,7 +4,7 @@ import { createRequire as __eveCreateRequire } from "node:module";
4
4
  const __filename = __eveFileURLToPath(import.meta.url);
5
5
  __eveDirname(__filename);
6
6
  __eveCreateRequire(import.meta.url);
7
- import { DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SegmentStreamAgentConfigError, configSchema, manifestOf } from "./config.mjs";
7
+ import { DEFAULT_ADMISSION_LIMITS, DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SegmentStreamAgentConfigError, configSchema, manifestOf } from "./config.mjs";
8
8
  import { chooseModel, model } from "./model.mjs";
9
9
  import { defineExtension } from "eve/extension";
10
10
  const extension = defineExtension({ config: configSchema });
@@ -12,4 +12,4 @@ const segmentstream = Object.assign(extension, {
12
12
  model: (choice) => model(choice ?? (() => extension.config.models.chat)),
13
13
  manifestOf
14
14
  });
15
- export { DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SegmentStreamAgentConfigError, chooseModel, segmentstream as default, segmentstream, manifestOf, model };
15
+ export { DEFAULT_ADMISSION_LIMITS, DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SegmentStreamAgentConfigError, chooseModel, segmentstream as default, segmentstream, manifestOf, model };
@@ -0,0 +1,13 @@
1
+ import type { AdmissionSettings, LocalizedText } from "../config.js";
2
+ import type { AgentIdentities, IdentityResolution, ResolveIdentityInput } from "./identities.js";
3
+ /** Sender language, then English, then the first configured entry. No platform copy. */
4
+ export declare function localizedText(texts: LocalizedText | undefined, languageCode?: string): string | undefined;
5
+ /** Channel-independent admission and at-least-once versioned notice delivery. */
6
+ export declare function admitMessage(input: {
7
+ readonly identity: ResolveIdentityInput;
8
+ readonly identities: AgentIdentities;
9
+ readonly settings: AdmissionSettings;
10
+ readonly languageCode?: string;
11
+ readonly sendNotice: (text: string) => Promise<unknown>;
12
+ readonly noticeFailed: (error: unknown) => void;
13
+ }): Promise<IdentityResolution>;
@@ -0,0 +1,42 @@
1
+ import { fileURLToPath as __eveFileURLToPath } from "node:url";
2
+ import { dirname as __eveDirname } from "node:path";
3
+ import { createRequire as __eveCreateRequire } from "node:module";
4
+ const __filename = __eveFileURLToPath(import.meta.url);
5
+ __eveDirname(__filename);
6
+ __eveCreateRequire(import.meta.url);
7
+ import { createHash } from "node:crypto";
8
+ function localizedText(texts, languageCode) {
9
+ if (texts === void 0) return;
10
+ const entries = Object.entries(texts);
11
+ const language = languageCode?.toLowerCase();
12
+ const exact = entries.find(([locale]) => locale.toLowerCase() === language);
13
+ const base = entries.find(([locale]) => locale.toLowerCase() === language?.split("-")[0]);
14
+ const english = entries.find(([locale]) => locale.toLowerCase() === "en");
15
+ return (exact ?? base ?? english ?? entries[0])?.[1];
16
+ }
17
+ async function admitMessage(input) {
18
+ const firstContact = input.settings.firstContact;
19
+ const notice = localizedText(firstContact?.notice, input.languageCode);
20
+ const sha256 = notice === void 0 ? void 0 : createHash("sha256").update(notice).digest("hex");
21
+ const resolved = await input.identities.resolve({
22
+ ...input.identity,
23
+ limits: input.settings.limits,
24
+ ...firstContact === null ? {} : {
25
+ noticeVersion: firstContact.version,
26
+ noticeSha256: sha256
27
+ }
28
+ });
29
+ if ((resolved.status === "existing" || resolved.status === "created") && resolved.noticeDue && input.identity.turn && input.identity.chat === "private" && firstContact !== null && notice !== void 0 && sha256 !== void 0) try {
30
+ await input.sendNotice(notice);
31
+ await input.identities.notice({
32
+ provider: input.identity.provider,
33
+ externalId: input.identity.externalId,
34
+ version: firstContact.version,
35
+ sha256
36
+ });
37
+ } catch (error) {
38
+ input.noticeFailed(error);
39
+ }
40
+ return resolved;
41
+ }
42
+ export { admitMessage, localizedText };
@@ -9,7 +9,7 @@ export declare function conversationCoordinates(context: {
9
9
  }): {
10
10
  provider: string;
11
11
  chatId: string;
12
- chatType: "channel" | "group" | "private";
12
+ chatType: "private" | "group" | "channel";
13
13
  threadId: string | null;
14
14
  participantIds: string[];
15
15
  senderId: string | null;
@@ -1,3 +1,4 @@
1
+ import type { AdmissionLimitsInput } from "../config.js";
1
2
  import type { RuntimeToken } from "./runtime-token.js";
2
3
  /**
3
4
  * The ontology service's pairing routes (`agent-design.md`, section 13, "Identity"). The agent
@@ -25,6 +26,36 @@ export type IdentityLookup = {
25
26
  | {
26
27
  readonly status: "refused";
27
28
  };
29
+ export type IdentityResolution = (({
30
+ readonly status: "existing";
31
+ } | {
32
+ readonly status: "created";
33
+ }) & {
34
+ readonly member: PairedMember;
35
+ readonly provisioned: boolean;
36
+ readonly noticeDue: boolean;
37
+ readonly onNewUserPending: boolean;
38
+ }) | {
39
+ readonly status: "pairing_required";
40
+ } | {
41
+ readonly status: "refused";
42
+ readonly reason?: "blocked" | "inactive";
43
+ } | {
44
+ readonly status: "limited";
45
+ readonly scope: "turnsPerMinute" | "turnsPerDay" | "newUsersPerHour" | "workspaceTurnsPerDay";
46
+ readonly retryAfterSeconds: number;
47
+ readonly notify: boolean;
48
+ readonly notificationId?: string;
49
+ };
50
+ export interface ResolveIdentityInput extends ChatAccount {
51
+ readonly chat: "private" | "group";
52
+ readonly turn: boolean;
53
+ readonly display?: string;
54
+ readonly username?: string;
55
+ readonly limits?: AdmissionLimitsInput;
56
+ readonly noticeVersion?: string;
57
+ readonly noticeSha256?: string;
58
+ }
28
59
  /** A pairing code the member approves at `/agent/connect?code=<code>`. */
29
60
  export interface Pairing {
30
61
  readonly code: string;
@@ -44,6 +75,16 @@ export interface AgentChat {
44
75
  }
45
76
  export interface AgentIdentities {
46
77
  lookup(account: ChatAccount): Promise<IdentityLookup>;
78
+ /** Server-owned admission; only an older server's route 404 falls back to GET lookup. */
79
+ resolve(input: ResolveIdentityInput): Promise<IdentityResolution>;
80
+ notice(input: ChatAccount & {
81
+ readonly version: string;
82
+ readonly sha256: string;
83
+ }): Promise<void>;
84
+ completeLimitNotice(input: {
85
+ readonly notificationId: string;
86
+ readonly delivered: boolean;
87
+ }): Promise<void>;
47
88
  /** Issues a pairing code, or returns the account's pending one while it has time left. */
48
89
  createPairing(account: ChatAccount & {
49
90
  readonly display?: string;
@@ -58,6 +58,33 @@ function pairing(value) {
58
58
  expiresAt: value.expires_at
59
59
  };
60
60
  }
61
+ function resolution(value) {
62
+ if (isRecord(value)) {
63
+ if ((value.status === "existing" || value.status === "created") && typeof value.provisioned === "boolean" && typeof value.notice_due === "boolean" && typeof value.on_new_user_pending === "boolean") return {
64
+ status: value.status,
65
+ member: pairedMember(value),
66
+ provisioned: value.provisioned,
67
+ noticeDue: value.notice_due,
68
+ onNewUserPending: value.on_new_user_pending
69
+ };
70
+ if (value.status === "pairing_required") return { status: "pairing_required" };
71
+ if (value.status === "refused" && (value.reason === "blocked" || value.reason === "inactive")) return {
72
+ status: "refused",
73
+ reason: value.reason
74
+ };
75
+ if (value.status === "limited" && (value.scope === "turnsPerMinute" || value.scope === "turnsPerDay" || value.scope === "newUsersPerHour" || value.scope === "workspaceTurnsPerDay") && typeof value.retry_after_seconds === "number" && Number.isInteger(value.retry_after_seconds) && value.retry_after_seconds >= 0 && typeof value.notify === "boolean") return {
76
+ status: "limited",
77
+ scope: value.scope,
78
+ retryAfterSeconds: value.retry_after_seconds,
79
+ notify: value.notify,
80
+ ...typeof value.notification_id === "string" ? { notificationId: value.notification_id } : {}
81
+ };
82
+ }
83
+ throw new AgentIdentityError({
84
+ code: "invalid_response",
85
+ message: "The ontology service answered the identity resolution with an invalid body."
86
+ });
87
+ }
61
88
  function nullableString(value) {
62
89
  return value === null || typeof value === "string";
63
90
  }
@@ -138,18 +165,59 @@ function agentIdentities(input) {
138
165
  });
139
166
  }
140
167
  }
168
+ async function lookup(account) {
169
+ const response = await call(`/identities/${encodeURIComponent(account.provider)}/${encodeURIComponent(account.externalId)}`, {});
170
+ if (response.status === 404) return { status: "unknown" };
171
+ if (response.status === 403) return { status: "refused" };
172
+ if (!response.ok) throw await safeRefusal(response, "identity lookup");
173
+ const member = pairedMember(await response.json());
174
+ pending.delete(accountKey(account));
175
+ return {
176
+ status: "paired",
177
+ member
178
+ };
179
+ }
141
180
  return {
142
- async lookup(account) {
143
- const response = await call(`/identities/${encodeURIComponent(account.provider)}/${encodeURIComponent(account.externalId)}`, {});
144
- if (response.status === 404) return { status: "unknown" };
145
- if (response.status === 403) return { status: "refused" };
146
- if (!response.ok) throw await safeRefusal(response, "identity lookup");
147
- const member = pairedMember(await response.json());
148
- pending.delete(accountKey(account));
149
- return {
150
- status: "paired",
151
- member
152
- };
181
+ lookup,
182
+ async resolve(account) {
183
+ const { externalId, noticeVersion, noticeSha256, ...details } = account;
184
+ const response = await call("/identities/resolve", { body: {
185
+ ...details,
186
+ external_id: externalId,
187
+ ...noticeVersion === void 0 ? {} : { notice_version: noticeVersion },
188
+ ...noticeSha256 === void 0 ? {} : { notice_sha256: noticeSha256 }
189
+ } });
190
+ if (response.status === 404) {
191
+ const existing = await lookup(account);
192
+ if (existing.status === "unknown") return { status: "pairing_required" };
193
+ return existing.status === "paired" ? {
194
+ ...existing,
195
+ status: "existing",
196
+ provisioned: false,
197
+ noticeDue: false,
198
+ onNewUserPending: false
199
+ } : existing;
200
+ }
201
+ if (!response.ok) throw await safeRefusal(response, "identity resolution");
202
+ const resolved = resolution(await response.json());
203
+ if (resolved.status === "existing" || resolved.status === "created") pending.delete(accountKey(account));
204
+ return resolved;
205
+ },
206
+ async completeLimitNotice(input) {
207
+ const response = await call("/identities/limit-notice", { body: {
208
+ notification_id: input.notificationId,
209
+ delivered: input.delivered
210
+ } });
211
+ if (!response.ok) throw await safeRefusal(response, "limit notice completion");
212
+ },
213
+ async notice(account) {
214
+ const response = await call("/identities/notice", { body: {
215
+ provider: account.provider,
216
+ external_id: account.externalId,
217
+ version: account.version,
218
+ sha256: account.sha256
219
+ } });
220
+ if (!response.ok) throw await safeRefusal(response, "identity notice acknowledgement");
153
221
  },
154
222
  async createPairing(account) {
155
223
  const key = accountKey(account);
@@ -5,7 +5,7 @@ const __filename = __eveFileURLToPath(import.meta.url);
5
5
  __eveDirname(__filename);
6
6
  __eveCreateRequire(import.meta.url);
7
7
  import { appOrigin } from "./environment.mjs";
8
- import { memberIdOf } from "./telegram/principal.mjs";
8
+ import { identityProviderOf, memberIdOf } from "./telegram/principal.mjs";
9
9
  import { CHAT_POST_ENVELOPE, chatPostDigest, chatPostOperationId, isChatPost } from "./chat-post-confirmation.mjs";
10
10
  import { actionApproval, confirmations } from "./confirmations.mjs";
11
11
  import { runtimeToken, tokenExpiry } from "./runtime-token.mjs";
@@ -20,7 +20,11 @@ function actAsHeaders(input) {
20
20
  const memberId = memberIdOf(input.principal);
21
21
  if (memberId === void 0) throw new MemberRequiredError("The ontology is reachable only for a member paired with this agent.");
22
22
  if (memberId !== input.expectedMemberId) throw new MemberRequiredError("The ontology connection was opened for another member.");
23
- return { [ACT_AS_HEADER]: memberId };
23
+ const provider = identityProviderOf(input.principal);
24
+ return {
25
+ [ACT_AS_HEADER]: memberId,
26
+ ...provider === void 0 ? {} : { "X-SegmentStream-Identity-Provider": provider }
27
+ };
24
28
  }
25
29
  function ontologyConnection(input) {
26
30
  const memberId = memberIdOf(input.principal);
@@ -1,8 +1,10 @@
1
+ import type { SessionAuthContext } from "eve/context";
1
2
  import type { ActionAnnotations } from "./confirmations.js";
2
3
  import { type Environment } from "./environment.js";
3
4
  import { type RuntimeToken } from "./runtime-token.js";
4
5
  /** A fresh, permission-filtered catalog. Listing tools cannot run an action's prepare step. */
5
6
  export declare function ontologyTools(options?: {
7
+ readonly principal?: SessionAuthContext | null;
6
8
  readonly environment?: Environment;
7
9
  readonly token?: RuntimeToken;
8
10
  readonly fetch?: typeof fetch;
@@ -5,12 +5,14 @@ const __filename = __eveFileURLToPath(import.meta.url);
5
5
  __eveDirname(__filename);
6
6
  __eveCreateRequire(import.meta.url);
7
7
  import { appOrigin } from "./environment.mjs";
8
+ import { identityProviderOf, memberIdOf } from "./telegram/principal.mjs";
8
9
  import { describeConfirmation, object } from "./confirmation-description.mjs";
9
10
  import { describeChatPost, isChatPost, supportsChatPost } from "./chat-post-confirmation.mjs";
10
11
  import { redactDiagnostic } from "./diagnostics.mjs";
11
12
  import { runtimeToken } from "./runtime-token.mjs";
12
13
  function ontologyTools(options = {}) {
13
14
  async function rpc(memberId, method, params, signal) {
15
+ const provider = memberIdOf(options.principal) === memberId ? identityProviderOf(options.principal) : void 0;
14
16
  const credential = await (options.token ?? runtimeToken)();
15
17
  const secrets = new Set([credential]);
16
18
  try {
@@ -19,6 +21,7 @@ function ontologyTools(options = {}) {
19
21
  headers: {
20
22
  authorization: `Bearer ${credential}`,
21
23
  "X-SegmentStream-Act-As": memberId,
24
+ ...provider === void 0 ? {} : { "X-SegmentStream-Identity-Provider": provider },
22
25
  "content-type": "application/json",
23
26
  accept: "application/json, text/event-stream",
24
27
  "mcp-protocol-version": "2025-11-25"
@@ -1,6 +1,6 @@
1
1
  import { type AttachmentDescriber } from "./attachment-description.js";
2
2
  import { type TelegramChannel } from "eve/channels/telegram";
3
- import type { ChatModelChoice, SessionSettings, TelegramSettings } from "../../config.js";
3
+ import type { ChatModelChoice, AdmissionSettings, SessionSettings, TelegramSettings } from "../../config.js";
4
4
  import { type AgentAttachments } from "../attachments.js";
5
5
  import { type Environment } from "../environment.js";
6
6
  import { type Conversations } from "../conversations.js";
@@ -30,6 +30,7 @@ export interface TelegramAdapterOptions {
30
30
  };
31
31
  /** The mounted `sessions`. */
32
32
  readonly sessions: () => SessionSettings;
33
+ readonly admission?: () => AdmissionSettings;
33
34
  readonly confirmationMode?: () => "deferred" | "held";
34
35
  readonly decide: DecideFunction;
35
36
  readonly environment?: Environment;
@@ -4,7 +4,7 @@ import { createRequire as __eveCreateRequire } from "node:module";
4
4
  const __filename = __eveFileURLToPath(import.meta.url);
5
5
  __eveDirname(__filename);
6
6
  __eveCreateRequire(import.meta.url);
7
- import "../../config.mjs";
7
+ import { manifestOf } from "../../config.mjs";
8
8
  import { appOrigin, channelsEnabled, proxyEnvironment, runtimeIdentityEnvironment, webhookSecret } from "../environment.mjs";
9
9
  import { conversationLanguage, telegramPrincipal } from "./principal.mjs";
10
10
  import { chooseModel } from "../../model.mjs";
@@ -165,6 +165,7 @@ function segmentstreamTelegram(options) {
165
165
  dependencies: {
166
166
  policy: settings().policy,
167
167
  sessions: options.sessions,
168
+ admission: options.admission ?? (() => manifestOf({})),
168
169
  sessionPolicy: policy,
169
170
  sttModel: options.models().stt,
170
171
  attachmentsModel: options.models().imageDescription ?? options.models().attachments ?? "google/gemini-2.5-flash-lite",
@@ -130,11 +130,15 @@ async function captureTelegramWebhook(input) {
130
130
  attachments,
131
131
  at: new Date(sentAt).toISOString()
132
132
  });
133
- await input.archive.append(event);
133
+ let persisted;
134
134
  const capture = {
135
135
  event,
136
136
  message,
137
137
  archive: input.archive,
138
+ persist: () => {
139
+ persisted ??= input.archive.append(capture.event).then(() => void 0);
140
+ return persisted;
141
+ },
138
142
  ...lookup === void 0 ? {} : { lookup }
139
143
  };
140
144
  const revisionId = edited === void 0 ? void 0 : `telegram:${message.chat.id}:${message.messageId}:routing:${createHash("sha256").update(JSON.stringify({
@@ -161,6 +165,7 @@ async function captureTelegramWebhook(input) {
161
165
  ...message.messageThreadId === void 0 ? {} : { messageThreadId: message.messageThreadId }
162
166
  });
163
167
  const automated = message.from === void 0 || message.from.isBot || message.chat.type === "channel";
168
+ if (automated || edited !== void 0 || message.raw.left_chat_member || message.raw.migrate_to_chat_id || message.raw.migrate_from_chat_id) await capture.persist();
164
169
  const history = automated ? void 0 : await input.archive.context({
165
170
  provider: "telegram",
166
171
  chatId: message.chat.id,
@@ -200,6 +205,7 @@ async function captureTelegramWebhook(input) {
200
205
  if (unchangedAttachments && event.attachments.length > 0) {
201
206
  const decoded = Array.isArray(previousRouting.decoded) ? previousRouting.decoded : [];
202
207
  const media = typeof previousRouting.media === "string" ? previousRouting.media : void 0;
208
+ await capture.persist();
203
209
  const metadata = record(JSON.parse(capture.event.argsSummary ?? "{}"));
204
210
  capture.event = {
205
211
  ...capture.event,
@@ -269,7 +275,7 @@ async function captureTelegramWebhook(input) {
269
275
  if (latest !== void 0 && !revisionEvents.some((item) => item.eventId === `${latest.eventId}:failed`) && Date.now() - Date.parse(latest.at) < 12e4) return false;
270
276
  const attempt = claims.length === 0 ? void 0 : Math.max(...claims.map((item) => Number(item.eventId.slice(revisionId.length).replace(":retry:", "")))) + 1;
271
277
  const claim = {
272
- ...event,
278
+ ...capture.event,
273
279
  eventId: attempt === void 0 ? revisionId : `${revisionId}:retry:${attempt}`,
274
280
  at: new Date(Date.now()).toISOString(),
275
281
  kind: "decision",
@@ -289,7 +295,7 @@ async function captureTelegramWebhook(input) {
289
295
  let accepted = false;
290
296
  let dispatchStarted = false;
291
297
  const claimEvent = (reason, status, suffix = "") => ({
292
- ...event,
298
+ ...capture.event,
293
299
  eventId: `${claimId}${suffix}`,
294
300
  kind: "decision",
295
301
  decision: "reply",
@@ -331,9 +337,10 @@ async function captureTelegramWebhook(input) {
331
337
  }) }) : alreadyHandled ? new Request(input.request, { body: JSON.stringify({ update_id: update.update_id }) }) : void 0);
332
338
  } catch (error) {
333
339
  try {
340
+ await capture.persist();
334
341
  await inbound?.finishClaim?.(false);
335
342
  await input.archive.append({
336
- ...event,
343
+ ...capture.event,
337
344
  eventId: `${event.eventId}:decision`,
338
345
  kind: "decision",
339
346
  text: null,
@@ -350,14 +357,15 @@ async function captureTelegramWebhook(input) {
350
357
  }
351
358
  throw error;
352
359
  }
360
+ await capture.persist();
353
361
  await inbound?.finishClaim?.(false);
354
362
  await input.archive.append({
355
- ...event,
363
+ ...capture.event,
356
364
  eventId: `${event.eventId}:decision`,
357
365
  kind: "decision",
358
366
  argsSummary: JSON.stringify({
359
367
  ...capture.diagnostics === void 0 ? {} : { decision: capture.diagnostics },
360
- routing: { membership: capture.lookup === void 0 ? "unknown" : capture.lookup.status === "paired" ? "linked" : "unlinked" }
368
+ routing: { membership: capture.lookup === void 0 ? "unknown" : capture.lookup.status === "paired" || capture.lookup.status === "existing" || capture.lookup.status === "created" ? "linked" : "unlinked" }
361
369
  }),
362
370
  text: capture.note ?? (edited !== void 0 ? "Message edit recorded as context; no agent turn started." : null),
363
371
  decision: capture.decision ?? "silent",
@@ -402,8 +410,9 @@ async function recordInboundContent(content) {
402
410
  const inbound = currentInboundRequest();
403
411
  const capture = inbound === void 0 ? void 0 : captures.get(inbound);
404
412
  if (capture?.archive === void 0) return;
413
+ await capture.persist();
405
414
  const metadata = record(JSON.parse(capture.event.argsSummary ?? "{}"));
406
- const membership = capture.lookup === void 0 ? record(metadata.routing).membership : capture.lookup.status === "paired" ? "linked" : "unlinked";
415
+ const membership = capture.lookup === void 0 ? record(metadata.routing).membership : capture.lookup.status === "paired" || capture.lookup.status === "existing" || capture.lookup.status === "created" ? "linked" : "unlinked";
407
416
  if (content.text === capture.event.text && content.media === record(metadata.routing).media && membership === record(metadata.routing).membership && JSON.stringify(content.decoded) === JSON.stringify(record(metadata.routing).decoded)) return;
408
417
  const contentHash = createHash("sha256").update(JSON.stringify({
409
418
  ...content,
@@ -507,13 +516,40 @@ function captureTelegramFetch(fetchImpl, archive, identities) {
507
516
  function conversationIdentities(identities) {
508
517
  return {
509
518
  ...identities,
519
+ resolve: async (account) => {
520
+ const resolved = await identities.resolve(account);
521
+ const inbound = currentInboundRequest();
522
+ const capture = inbound === void 0 ? void 0 : captures.get(inbound);
523
+ if (account.provider === "telegram" && capture?.message.from?.id === account.externalId) {
524
+ capture.lookup = resolved;
525
+ if (capture.event.chatType === "private" && (resolved.status === "existing" || resolved.status === "created")) capture.event = {
526
+ ...capture.event,
527
+ participantIds: [resolved.member.id]
528
+ };
529
+ if (capture.event.chatType === "private" && resolved.status === "limited") {
530
+ const binding = await identities.lookup(account);
531
+ if (binding.status === "paired") capture.event = {
532
+ ...capture.event,
533
+ participantIds: [binding.member.id]
534
+ };
535
+ }
536
+ await capture.persist();
537
+ }
538
+ return resolved;
539
+ },
510
540
  lookup: async (account) => {
511
541
  const inbound = currentInboundRequest();
512
542
  const capture = inbound === void 0 ? void 0 : captures.get(inbound);
513
- if (account.provider === "telegram" && capture?.message.from?.id === account.externalId && capture.lookup !== void 0) return capture.lookup;
514
- const lookup = await identities.lookup(account);
515
- if (account.provider === "telegram" && capture?.message.from?.id === account.externalId) capture.lookup = lookup;
516
- return lookup;
543
+ if (account.provider === "telegram" && capture?.message.from?.id === account.externalId) {
544
+ const cached = capture.lookup;
545
+ if (cached?.status === "paired" || cached?.status === "refused" || cached?.status === "unknown") return cached;
546
+ if (cached?.status === "existing" || cached?.status === "created") return {
547
+ status: "paired",
548
+ member: cached.member
549
+ };
550
+ if (cached?.status === "pairing_required") return { status: "unknown" };
551
+ }
552
+ return identities.lookup(account);
517
553
  }
518
554
  };
519
555
  }
@@ -1,5 +1,5 @@
1
1
  import type { TelegramContext, TelegramInboundResult, TelegramMessage } from "eve/channels/telegram";
2
- import type { SessionSettings, TelegramPolicy } from "../../config.js";
2
+ import type { AdmissionSettings, SessionSettings, TelegramPolicy } from "../../config.js";
3
3
  import type { AgentAttachments } from "../attachments.js";
4
4
  import type { AgentIdentities, PairedMember } from "../identities.js";
5
5
  import type { BotIdentityLookup } from "./bot.js";
@@ -38,6 +38,7 @@ import type { PdfTextExtractor } from "./pdf-text.js";
38
38
  */
39
39
  export interface InboundDependencies {
40
40
  readonly policy: TelegramPolicy;
41
+ readonly admission: () => AdmissionSettings;
41
42
  readonly sessions: () => SessionSettings;
42
43
  readonly sessionPolicy: SessionPolicy;
43
44
  readonly sttModel: string | false;
@@ -8,7 +8,7 @@ import { conversationLanguage, telegramPrincipal } from "./principal.mjs";
8
8
  import { p as reportTelegramError } from "../../../_chunks/inbound-request-BwBvXk0A.mjs";
9
9
  import { formatAddress, messageAddress, scopedAddress } from "./sessions.mjs";
10
10
  import { contentShape, messageContent } from "./content.mjs";
11
- import { messageAudience } from "./audience.mjs";
11
+ import { messageAudience, otherRecipient } from "./audience.mjs";
12
12
  import { attachmentKind, messageMetadata, observedChatKey, observedContext } from "./observed.mjs";
13
13
  import { MAXIMUM_NATIVE_IMAGE_BYTES } from "./images.mjs";
14
14
  import { MAXIMUM_ROUTING_ATTACHMENT_BYTES, describeRoutingAttachment, routingAttachmentDeadline } from "./attachment-description.mjs";
@@ -19,6 +19,7 @@ import { questionAddress, questionFromHistory } from "./confirmations.mjs";
19
19
  import { routingContext } from "./routing-context.mjs";
20
20
  import { recordGateDecision, recordInboundContent, recordRouteDecision } from "./conversations.mjs";
21
21
  import { chatContext } from "./chats.mjs";
22
+ import { admitMessage, localizedText } from "../admission.mjs";
22
23
  import { attachmentsContext, inboundAttachments, stageFiles } from "./files.mjs";
23
24
  import { voiceOf, voiceText } from "./voice.mjs";
24
25
  const UNKNOWN_PRIVATE = "🔗";
@@ -94,13 +95,39 @@ function unknownInGroup(bot) {
94
95
  }
95
96
  async function answerUnpaired(input) {
96
97
  const { context, message, lookup, dependencies } = input;
98
+ if (lookup.status === "limited") {
99
+ if (lookup.notify) {
100
+ const replies = dependencies.admission().replies;
101
+ const configured = lookup.scope === "workspaceTurnsPerDay" ? replies.workspaceLimited : lookup.scope === "newUsersPerHour" ? replies.registrationsLimited : replies.perSenderLimited;
102
+ const text = localizedText(configured ?? replies.limited, message.from.languageCode);
103
+ if (text !== void 0) {
104
+ let delivered = false;
105
+ try {
106
+ await context.telegram.post(text);
107
+ delivered = true;
108
+ } finally {
109
+ if (lookup.notificationId !== void 0) try {
110
+ await dependencies.identities.completeLimitNotice({
111
+ notificationId: lookup.notificationId,
112
+ delivered
113
+ });
114
+ } catch (error) {
115
+ reportTelegramError("limit_notice_completion_failed", error);
116
+ }
117
+ }
118
+ return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
97
123
  if (lookup.status === "refused") {
98
- await context.telegram.post(REFUSED);
99
- return;
124
+ const text = localizedText(dependencies.admission().replies.refused, message.from.languageCode);
125
+ await context.telegram.post(text ?? REFUSED);
126
+ return true;
100
127
  }
101
128
  if (message.chat.type !== "private") {
102
- await context.telegram.post(unknownInGroup(input.bot));
103
- return;
129
+ await context.telegram.post(localizedText(dependencies.admission().replies.groupPairingRequired, message.from.languageCode) ?? unknownInGroup(input.bot));
130
+ return true;
104
131
  }
105
132
  const sender = display(message.from);
106
133
  const pairing = await dependencies.identities.createPairing({
@@ -112,6 +139,7 @@ async function answerUnpaired(input) {
112
139
  text: UNKNOWN_PRIVATE,
113
140
  reply_markup: buttonTo("🔗", dependencies.connectUrl(pairing.code))
114
141
  });
142
+ return true;
115
143
  }
116
144
  function isGroupChat(message) {
117
145
  return message.chat.type === "group" || message.chat.type === "supergroup";
@@ -190,10 +218,41 @@ async function handleInboundMessage(input) {
190
218
  const bot = await dependencies.bot(context.telegram);
191
219
  const audience = messageAudience(sent, bot);
192
220
  const addressed = addressingReason(sent, bot);
193
- const lookup = await dependencies.identities.lookup({
221
+ const elsewhere = otherRecipient(audience);
222
+ if (input.request?.edited) {
223
+ if (addressed === void 0 && !input.request.attachmentsChanged && (input.request.previousText ?? "").trim().replace(/\s+/gu, " ") === audience.originalText.trim().replace(/\s+/gu, " ")) {
224
+ recordGateDecision("message_edit");
225
+ return null;
226
+ }
227
+ if (input.request.claimRevision !== void 0 && !await input.request.claimRevision()) {
228
+ recordGateDecision("message_revision_already_handled");
229
+ return null;
230
+ }
231
+ }
232
+ const sender = display(sent.from);
233
+ const hasContent = (sent.text || sent.caption).trim() !== "" || voiceOf(sent.raw) !== void 0 || sent.attachments.length > 0;
234
+ const identity = {
194
235
  provider: "telegram",
195
- externalId: sent.from.id
236
+ externalId: sent.from.id,
237
+ chat: sent.chat.type === "private" ? "private" : "group",
238
+ ...sender === void 0 ? {} : { display: sender },
239
+ ...sent.from.username === void 0 ? {} : { username: sent.from.username }
240
+ };
241
+ const resolve = (turn) => admitMessage({
242
+ identity: {
243
+ ...identity,
244
+ turn
245
+ },
246
+ identities: dependencies.identities,
247
+ settings: dependencies.admission(),
248
+ languageCode: sent.from.languageCode,
249
+ sendNotice: (text) => context.telegram.post(text),
250
+ noticeFailed: (error) => {
251
+ reportTelegramError("identity_notice_failed", error, { provider: "telegram" });
252
+ }
196
253
  });
254
+ const turn = addressed !== void 0 && hasContent && (addressed !== "reply-to-bot" || elsewhere === void 0);
255
+ const lookup = await resolve(turn);
197
256
  const group = isGroupChat(sent);
198
257
  const chat = observedChatKey({
199
258
  chatId: sent.chat.id,
@@ -202,7 +261,7 @@ async function handleInboundMessage(input) {
202
261
  const report = group ? dependencies.chats.seen({
203
262
  chatId: sent.chat.id,
204
263
  title: sent.chat.title ?? "",
205
- ...lookup.status === "paired" ? { memberId: lookup.member.id } : {}
264
+ ...lookup.status === "existing" || lookup.status === "created" ? { memberId: lookup.member.id } : {}
206
265
  }) : void 0;
207
266
  if (report !== void 0) input.request?.waitUntil?.(report);
208
267
  const voice = voiceOf(sent.raw);
@@ -266,37 +325,43 @@ async function handleInboundMessage(input) {
266
325
  text = [caption, unavailable].filter(Boolean).join("\n\n");
267
326
  }
268
327
  };
269
- if (lookup.status !== "paired") {
270
- if (group) {
271
- const retained = input.request?.edited ? void 0 : dependencies.observed.recent(chat).find((entry) => entry.messageId === sent.messageId);
272
- if (retained === void 0) {
273
- if (voice !== void 0) await transcribeVoice(true);
274
- await recordInboundContent({
275
- text,
276
- decoded
277
- });
278
- }
279
- dependencies.observed.add(chat, retained ?? observedMessage(sent, text || "[attachment]", void 0, notes.join("\n"), bot));
280
- }
281
- recordGateDecision(`identity_${lookup.status}`, !group && addressed !== void 0 ? "reply" : "silent");
282
- if (addressed !== void 0 && !group) await answerUnpaired({
328
+ async function reject(refused, reply) {
329
+ const replied = hasContent && reply && (refused.status !== "limited" || refused.notify) ? await answerUnpaired({
283
330
  context,
284
331
  message: sent,
285
- lookup,
332
+ lookup: refused,
286
333
  bot,
287
334
  dependencies
335
+ }) : false;
336
+ recordGateDecision("identity_" + refused.status, replied ? "reply" : "silent");
337
+ }
338
+ async function observeRaw() {
339
+ await recordInboundContent({
340
+ text,
341
+ decoded
288
342
  });
343
+ if (group) dependencies.observed.add(chat, observedMessage(sent, text || "[attachment]", void 0, void 0, bot));
344
+ }
345
+ if (lookup.status !== "existing" && lookup.status !== "created") {
346
+ const configuredGroupPairing = lookup.status === "pairing_required" && localizedText(dependencies.admission().replies.groupPairingRequired, sent.from.languageCode) !== void 0;
347
+ await reject(lookup, addressed !== void 0 && (!group || lookup.status === "limited" || configuredGroupPairing));
348
+ await observeRaw();
289
349
  return null;
290
350
  }
291
- if (input.request?.edited) {
292
- if (addressed === void 0 && !input.request.attachmentsChanged && (input.request.previousText ?? "").trim().replace(/\s+/gu, " ") === audience.originalText.trim().replace(/\s+/gu, " ")) {
293
- recordGateDecision("message_edit");
294
- return null;
295
- }
296
- if (input.request.claimRevision !== void 0 && !await input.request.claimRevision()) {
297
- recordGateDecision("message_revision_already_handled");
298
- return null;
351
+ let member = lookup.member;
352
+ let turnAdmitted = turn || !lookup.provisioned || member.roles.length !== 1 || member.roles[0] !== "member";
353
+ let admissionRefused = false;
354
+ async function admitTurn(reply) {
355
+ if (turnAdmitted) return true;
356
+ const admitted = await resolve(true);
357
+ if (admitted.status !== "existing" && admitted.status !== "created") {
358
+ admissionRefused = true;
359
+ await reject(admitted, reply);
360
+ return false;
299
361
  }
362
+ member = admitted.member;
363
+ turnAdmitted = true;
364
+ return true;
300
365
  }
301
366
  let address = scopedAddress(messageAddress(sent), dependencies.sessions().scope);
302
367
  const operations = input.request?.sessions;
@@ -306,7 +371,7 @@ async function handleInboundMessage(input) {
306
371
  conversationId: ""
307
372
  });
308
373
  const questionOwner = {
309
- memberId: lookup.member.id,
374
+ memberId: member.id,
310
375
  chatId: sent.chat.id,
311
376
  messageThreadId: String(sent.messageThreadId ?? "")
312
377
  };
@@ -315,7 +380,7 @@ async function handleInboundMessage(input) {
315
380
  const owner = questionSession ?? await operations?.resolveSession(address) ?? await operations?.resolveSession(topicAddress);
316
381
  const questionHistory = owner === void 0 || dependencies.history === void 0 ? [] : await dependencies.history(owner.id);
317
382
  const pending = questionFromHistory(questionHistory, questionOwner);
318
- let answering = pending !== void 0 && pending.memberId === lookup.member.id && pending.chatId === sent.chat.id && pending.messageThreadId === String(sent.messageThreadId ?? "");
383
+ let answering = pending !== void 0 && pending.memberId === member.id && pending.chatId === sent.chat.id && pending.messageThreadId === String(sent.messageThreadId ?? "");
319
384
  const asked = questionHistory.findLast((event) => {
320
385
  const data = isRecord(event.data) ? event.data : {};
321
386
  const result = isRecord(data.result) ? data.result : {};
@@ -328,7 +393,27 @@ async function handleInboundMessage(input) {
328
393
  address = pending.address || (questionSession === void 0 ? topicAddress : memberQuestionAddress);
329
394
  input.request.address = address;
330
395
  }
331
- const mediaPlan = voice !== void 0 || sent.attachments.length > 0 || dependencies.observed.list(chat).some((message) => (message.images?.length ?? 0) > 0) ? await dependencies.mediaPlan?.(lookup.member, owner?.id) : void 0;
396
+ if (group && (addressed === void 0 && !answering && dependencies.policy === "mention" || elsewhere !== void 0 && (addressed === void 0 || addressed === "reply-to-bot" && !recipientAnswer))) {
397
+ await recordInboundContent({
398
+ text,
399
+ decoded
400
+ });
401
+ dependencies.observed.add(chat, observedMessage(sent, text || "[attachment]", member, void 0, bot));
402
+ recordRouteDecision("observe", addressed, dependencies.policy, {
403
+ reason: elsewhere === void 0 ? "mention_policy" : "other_recipient",
404
+ note: elsewhere ?? "Mention policy observes unaddressed messages."
405
+ });
406
+ return null;
407
+ }
408
+ if (hasContent && answering && !await admitTurn(true)) {
409
+ await observeRaw();
410
+ return null;
411
+ }
412
+ if ((voice !== void 0 || sent.attachments.length > 0) && !await admitTurn(addressed !== void 0 || answering)) {
413
+ await observeRaw();
414
+ return null;
415
+ }
416
+ const mediaPlan = voice !== void 0 || sent.attachments.length > 0 || dependencies.observed.list(chat).some((message) => (message.images?.length ?? 0) > 0) ? await dependencies.mediaPlan?.(member, owner?.id) : void 0;
332
417
  if (voice !== void 0 && !group) await transcribeVoice(false);
333
418
  if (voice !== void 0 && mediaPlan?.audio) notes.push("[The chat model supports audio, but this host cannot deliver native audio. The configured speech-to-text fallback was used if enabled.]");
334
419
  if (text.trim() === "" && sent.attachments.length === 0) {
@@ -352,7 +437,11 @@ async function handleInboundMessage(input) {
352
437
  media_type: file.mediaType,
353
438
  size: file.size
354
439
  })) })];
355
- let media = [...attachmentMetadata, ...notes].join("\n");
440
+ let media = [
441
+ input.request?.previousContent?.media,
442
+ ...attachmentMetadata,
443
+ ...notes
444
+ ].filter(Boolean).join("\n");
356
445
  let processedMedia;
357
446
  let classified = false;
358
447
  let question;
@@ -386,7 +475,7 @@ async function handleInboundMessage(input) {
386
475
  recent: context.recent,
387
476
  pendingConfirmations: context.pendingConfirmations,
388
477
  pendingQuestions: context.pendingQuestions.filter((question) => question.chatId === sent.chat.id && question.messageThreadId === String(sent.messageThreadId ?? "")),
389
- message: observedMessage(sent, text, lookup.member, media, bot),
478
+ message: observedMessage(sent, text, member, media, bot),
390
479
  audience,
391
480
  captionlessAttachments: caption.trim() === "" ? messageMetadata(sent, bot).attachmentKinds : []
392
481
  };
@@ -394,12 +483,15 @@ async function handleInboundMessage(input) {
394
483
  };
395
484
  const route = await routeMessage({
396
485
  chatId: sent.chat.id,
397
- memberId: lookup.member.id,
486
+ memberId: member.id,
398
487
  policy: dependencies.policy,
399
488
  addressed: addressed ?? (answering ? "pending-question" : void 0),
400
489
  recipientAnswer,
401
490
  audience,
402
- record: (decision) => recordRouteDecision(decision.route, addressed, dependencies.policy, decision),
491
+ record: (decision) => {
492
+ if (decision.reason !== "admission_refused") recordRouteDecision(decision.route, addressed, dependencies.policy, decision);
493
+ },
494
+ beforeDecide: () => admitTurn(false),
403
495
  decide: dependencies.decide,
404
496
  question: async () => {
405
497
  classified = true;
@@ -447,11 +539,19 @@ async function handleInboundMessage(input) {
447
539
  return conversation();
448
540
  }
449
541
  });
450
- if (group && route === "observe") dependencies.observed.add(chat, observedMessage(sent, text, lookup.member, media, bot));
542
+ if (admissionRefused) {
543
+ await recordInboundContent({
544
+ text,
545
+ decoded
546
+ });
547
+ dependencies.observed.add(chat, observedMessage(sent, text || "[attachment]", member, void 0, bot));
548
+ return null;
549
+ }
550
+ if (group && route === "observe") dependencies.observed.add(chat, observedMessage(sent, text, member, media, bot));
451
551
  if (sent.attachments.length > 0) await report;
452
- let files = sent.attachments.length === 0 || dependencies.attachments === void 0 || input.request === void 0 ? new Map() : await stageFiles({
552
+ let files = input.request?.edited && !input.request.attachmentsChanged && (input.request.previousContent?.decoded.length ?? 0) > 0 || sent.attachments.length === 0 || dependencies.attachments === void 0 || input.request === void 0 ? new Map() : await stageFiles({
453
553
  message: sent,
454
- memberId: lookup.member.id,
554
+ memberId: member.id,
455
555
  attachments: dependencies.attachments,
456
556
  download: async (fileId) => {
457
557
  const bytes = voiceBytes !== void 0 && voice?.fileId === fileId ? voiceBytes : routingBytes !== void 0 && routingFile?.fileId === fileId ? routingBytes : await dependencies.download(fileId);
@@ -487,7 +587,7 @@ async function handleInboundMessage(input) {
487
587
  if (route === "observe") {
488
588
  const images = [...files.values()].flatMap((file) => file.status === "stored" && file.imageInput === "native" ? [file.attachment] : []);
489
589
  dependencies.observed.add(chat, {
490
- ...observedMessage(sent, text === "" ? "[attachment]" : text, lookup.member, media, bot),
590
+ ...observedMessage(sent, text === "" ? "[attachment]" : text, member, media, bot),
491
591
  images
492
592
  });
493
593
  return null;
@@ -539,7 +639,7 @@ async function handleInboundMessage(input) {
539
639
  type: "file",
540
640
  data: attachmentUrl({
541
641
  id: image.id,
542
- memberId: lookup.member.id,
642
+ memberId: member.id,
543
643
  conversation: `telegram:${sent.chat.id}`
544
644
  }),
545
645
  mediaType: image.contentType,
@@ -553,13 +653,13 @@ async function handleInboundMessage(input) {
553
653
  recordGateDecision("message_already_handled");
554
654
  return null;
555
655
  }
556
- dependencies.observed.remember(chat, observedMessage(sent, text, lookup.member, retainedMedia, bot));
656
+ dependencies.observed.remember(chat, observedMessage(sent, text, member, retainedMedia, bot));
557
657
  const principal = telegramPrincipal({
558
658
  message: {
559
659
  ...sent,
560
660
  text
561
661
  },
562
- member: lookup.member,
662
+ member,
563
663
  conversationLanguage: language
564
664
  });
565
665
  return {
@@ -575,7 +675,7 @@ async function handleInboundMessage(input) {
575
675
  ...group ? chatContext(dependencies.chats.chat(sent.chat.id)) : [],
576
676
  speakerBlock({
577
677
  message: sent,
578
- member: lookup.member,
678
+ member,
579
679
  bot,
580
680
  omitQuote: group && question?.recent.some((entry) => "role" in entry && entry.confirmation !== void 0 && entry.messageId === audience.replyTo?.messageId)
581
681
  }),
@@ -21,3 +21,5 @@ export declare function conversationLanguage(texts: readonly string[]): "ru" | "
21
21
  export declare function memberIdOf(principal: SessionAuthContext | null | undefined): string | undefined;
22
22
  /** The member's roles on a session principal; empty when it carries none. */
23
23
  export declare function rolesOf(principal: SessionAuthContext | null | undefined): ReadonlyArray<string>;
24
+ /** Verified channel provenance stamped by the channel adapter, never by model input. */
25
+ export declare function identityProviderOf(principal: SessionAuthContext | null | undefined): string | undefined;
@@ -13,6 +13,7 @@ function telegramPrincipal(input) {
13
13
  const sender = message.from.id;
14
14
  const attributes = {
15
15
  memberId: member.id,
16
+ identityProvider: "telegram",
16
17
  roles: member.roles,
17
18
  chatId: message.chat.id,
18
19
  chatType: message.chat.type
@@ -48,4 +49,8 @@ function rolesOf(principal) {
48
49
  if (typeof roles === "string") return [roles];
49
50
  return roles ?? [];
50
51
  }
51
- export { TELEGRAM_AUTHENTICATOR, conversationLanguage, memberIdOf, rolesOf, telegramPrincipal };
52
+ function identityProviderOf(principal) {
53
+ const provider = principal?.attributes.identityProvider;
54
+ return typeof provider === "string" && provider !== "" ? provider : void 0;
55
+ }
56
+ export { TELEGRAM_AUTHENTICATOR, conversationLanguage, identityProviderOf, memberIdOf, rolesOf, telegramPrincipal };
@@ -111,5 +111,6 @@ export declare function routeMessage(input: {
111
111
  readonly record?: (decision: RouteDecision) => void;
112
112
  /** Builds the question only when a decision is needed: it reads the agent's description. */
113
113
  readonly question: () => Promise<RouteQuestion>;
114
+ readonly beforeDecide?: () => Promise<boolean>;
114
115
  readonly decide: RouteDecider;
115
116
  }): Promise<Route>;
@@ -233,6 +233,8 @@ async function routeMessage(input) {
233
233
  if (elsewhere !== void 0) return complete("observe", "other_recipient", elsewhere);
234
234
  if (input.addressed === "reply-to-bot") return complete("reply", "reply-to-bot", "Bot addressed directly: reply-to-bot.");
235
235
  if (input.addressed === "pending-question") return complete("reply", "pending-question", "Answer to this member's pending agent question, without another explicit recipient.");
236
+ if (input.policy === "mention") return complete("observe", "mention_policy", "Mention policy observes unaddressed messages.");
237
+ if (input.beforeDecide !== void 0 && !await input.beforeDecide()) return complete("observe", "admission_refused", "Admission refused before decoding or inference.");
236
238
  try {
237
239
  const question = await input.question();
238
240
  const elsewhere = otherRecipient(question.audience);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream/agent",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Mount a SegmentStream workspace's agent on eve: the ontology as tools, acting as the member who writes, and Telegram through the workspace's connection.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "dependencies": {
64
64
  "@chat-adapter/telegram": "4.41.0",
65
- "@segmentstream-cloud/ontology": "0.33.0",
65
+ "@segmentstream-cloud/ontology": "0.33.1",
66
66
  "@vercel/oidc": "^3.8.4",
67
67
  "ai": "^7.0.128",
68
68
  "csv-parse": "^7.0.3",