@segmentstream/agent 0.1.3 → 0.1.5

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
@@ -97,6 +97,7 @@ platform's deployment manifest.
97
97
  | ------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------- |
98
98
  | `segmentstream__ontology` | `connections` | The app's `/mcp`, resolved per turn for the turn's member. No member, no connection. |
99
99
  | `segmentstream__telegram` | `channels` | `POST /eve/v1/telegram`: Telegram through the connection, with pairing, routing and voice. |
100
+ | | | `POST /eve/v1/telegram/messages`: requests from the ontology's `agent_chat.send_message`. |
100
101
  | platform rules | `instructions` | Changes go through actions; `describe` gives the catalog; tools are already narrowed; when to answer `[SILENT]` |
101
102
 
102
103
  ### The ontology
@@ -139,12 +140,39 @@ share a group chat's session never share a client.
139
140
  such as `[voice message, 12 s]` in context. A file over the proxy's 2 MiB limit, or a failed
140
141
  transcription, reaches the agent as a note saying so. Photos and documents up to 2 MiB reach the
141
142
  agent as eve's file parts.
143
+ - **Chat registry.** Every group message reports its chat to the ontology's `agent_chat` registry
144
+ (`POST /api/ontology/agent/chats/seen`) with its title and, when the sender is paired, the
145
+ member's id: at most once per chat every ten minutes per process, and at once for a member not
146
+ yet reported there. The report runs in the background; a failure is logged and changes nothing,
147
+ so the next message reports again.
148
+ An answered group turn waits for its report, bounded by the call's ten-second timeout, and
149
+ carries the registry's `title`, `purpose` and `whenToUse` as a `<chat>` block. When a paired
150
+ member leaves the group, or is removed, the agent reports it with `leftMemberId`, so the
151
+ registry unlinks them; it starts no turn. A later message from them links them again. When the
152
+ agent itself leaves or is removed, it reports `agentLeft: true`, so the registry unlinks every
153
+ member and keeps the chat; members link again when they write after the agent is back. When a
154
+ group becomes a supergroup, either notice (`migrate_to_chat_id` in the old chat,
155
+ `migrate_from_chat_id` in the new one) reports the new id with `migratedFrom: <old id>`, starts
156
+ no turn and drops the old chat from the memo. The `<chat>` block carries at most 1000
157
+ characters of `whenToUse`.
158
+ - **Requests.** `POST /eve/v1/telegram/messages` takes a request from the ontology's
159
+ `agent_chat.send_message`: a group or private chat, the request, the person to contact and the
160
+ requester, with their roles (empty when an older ontology sends none) and `actAs` (whether the
161
+ turn can act as them; without it, true for a member). The bearer must be a Vercel OIDC token of a production function of the same project,
162
+ verified with the team's issuer and audience. The request reaches the chat's session through
163
+ eve's proactive `receive` at `chatId::`, the address the chat's own messages continue, after the
164
+ session policy (idle reset, daily compaction) runs on it, with an `<agent_request>` block naming the requester and the person. A member's request with `actAs` runs as that
165
+ member, with their roles, which choose the model of a session the request starts; a service's,
166
+ or an unpaired member's (`actAs: false`), runs without a member and has no ontology, its roles
167
+ still choosing the model. It answers 202 with the session id, 503 outside
168
+ production, 404 without `channels.telegram`, 401 for a missing or invalid token and 400 for a
169
+ body that does not match, including a request over 4000 characters.
142
170
  - **Silence.** When the model answers a routed message with exactly `[SILENT]` (or `NO_REPLY`), in
143
171
  any case, nothing is sent to the chat. The turn stays in the session.
144
172
 
145
- The observed ring lives in process memory, per chat and forum topic, because eve's durable
173
+ The observed ring and the chat reports live in process memory, per chat and forum topic, because eve's durable
146
174
  `defineState` is session state and an observed message has no session. A new function instance
147
- starts with an empty ring.
175
+ starts with an empty ring and reports its chats afresh.
148
176
 
149
177
  ## Local development
150
178
 
@@ -163,17 +191,20 @@ manifest.
163
191
  The platform sets every variable on the workspace's Vercel project. None of them is a model key or
164
192
  a provider token.
165
193
 
166
- | Variable | Used for |
167
- | ------------------------------------ | ------------------------------------------------------------------------------- |
168
- | `VERCEL_PROJECT_PRODUCTION_URL` | The app's origin: `/mcp`, `/api/ontology/agent/*` and the `/agent/connect` link |
169
- | `SEGMENTSTREAM_AGENT_ONTOLOGY_URL` | Overrides that origin in local development |
170
- | `SEGMENTSTREAM_URL` | backend-next, where the connection proxy answers |
171
- | `SEGMENTSTREAM_WORKSPACE_ID` | The workspace in the proxy's path |
172
- | `SEGMENTSTREAM_ORGANIZATION_ID` | The tenant the proxy selects |
173
- | `SEGMENTSTREAM_AGENT_WEBHOOK_SECRET` | The Telegram webhook secret; the only secret |
174
- | `VERCEL_ENV` | Channels run only in `production`; `preview` selects the preview model |
175
- | `VERCEL_OIDC_TOKEN` | The runtime token outside a request; Vercel sends it on each request otherwise |
176
- | `AI_GATEWAY_API_KEY` | Optional. AI Gateway uses the OIDC token when it is unset |
194
+ | Variable | Used for |
195
+ | ------------------------------------- | ------------------------------------------------------------------------------- |
196
+ | `VERCEL_PROJECT_PRODUCTION_URL` | The app's origin: `/mcp`, `/api/ontology/agent/*` and the `/agent/connect` link |
197
+ | `SEGMENTSTREAM_AGENT_ONTOLOGY_URL` | Overrides that origin in local development |
198
+ | `SEGMENTSTREAM_URL` | backend-next, where the connection proxy answers |
199
+ | `SEGMENTSTREAM_WORKSPACE_ID` | The workspace in the proxy's path |
200
+ | `SEGMENTSTREAM_ORGANIZATION_ID` | The tenant the proxy selects |
201
+ | `SEGMENTSTREAM_AGENT_WEBHOOK_SECRET` | The Telegram webhook secret; the only secret |
202
+ | `SEGMENTSTREAM_RUNTIME_OIDC_ISSUER` | The team's OIDC issuer, which signs the tokens the messages route accepts |
203
+ | `SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE` | The audience those tokens must name |
204
+ | `VERCEL_PROJECT_ID` | The project those tokens must name |
205
+ | `VERCEL_ENV` | Channels run only in `production`; `preview` selects the preview model |
206
+ | `VERCEL_OIDC_TOKEN` | The runtime token outside a request; Vercel sends it on each request otherwise |
207
+ | `AI_GATEWAY_API_KEY` | Optional. AI Gateway uses the OIDC token when it is unset |
177
208
 
178
209
  ## License
179
210
 
@@ -10,6 +10,7 @@ import { decide } from "eve/ai";
10
10
  var telegram_default = segmentstreamTelegram({
11
11
  settings: () => segmentstream.config.channels.telegram,
12
12
  models: () => segmentstream.config.models,
13
+ sessions: () => segmentstream.config.sessions,
13
14
  decide: (options) => decide(options)
14
15
  });
15
16
  export { telegram_default as default };
@@ -49,6 +49,22 @@ export interface SegmentStreamAgentConfigInput {
49
49
  /** Whether the agent reaches the ontology over MCP. On by default. */
50
50
  readonly connection?: boolean;
51
51
  };
52
+ readonly sessions?: {
53
+ /**
54
+ * What a group session spans: `chat` (default) keeps one session per group chat and forum
55
+ * topic, `thread` keeps eve's one session per reply thread.
56
+ */
57
+ readonly scope?: string;
58
+ /**
59
+ * The local time, `HH:MM`, after which the first message of a chat compacts its session's
60
+ * history before the turn runs; `false` leaves compaction to eve's context-window check.
61
+ */
62
+ readonly compactAt?: string | boolean;
63
+ /** The IANA time zone `compactAt` is read in, such as `Europe/London`. */
64
+ readonly timezone?: string;
65
+ /** Hours of silence after which a chat's next message starts with an empty history. */
66
+ readonly idleResetHours?: number | boolean;
67
+ };
52
68
  }
53
69
  export interface TelegramSettings {
54
70
  readonly connection: string;
@@ -66,6 +82,17 @@ export interface ChatModelChoice {
66
82
  /** The model of preview deployments, so a preview never spends on a large model. */
67
83
  readonly preview?: string;
68
84
  }
85
+ export declare const SESSION_SCOPES: readonly ["chat", "thread"];
86
+ /** What one group session spans: the chat (and forum topic), or one reply thread. */
87
+ export type SessionScope = (typeof SESSION_SCOPES)[number];
88
+ /** How the Telegram adapter keeps sessions: their scope, the daily compaction and the idle reset. */
89
+ export interface SessionSettings {
90
+ readonly scope: SessionScope;
91
+ /** `HH:MM` in `timezone`, or `false`. */
92
+ readonly compactAt: string | false;
93
+ readonly timezone: string;
94
+ readonly idleResetHours: number | false;
95
+ }
69
96
  /** The validated configuration with defaults applied. It stays JSON-serializable. */
70
97
  export interface SegmentStreamAgentConfig {
71
98
  readonly channels: {
@@ -80,6 +107,7 @@ export interface SegmentStreamAgentConfig {
80
107
  readonly ontology: {
81
108
  readonly connection: boolean;
82
109
  };
110
+ readonly sessions: SessionSettings;
83
111
  }
84
112
  /** One validation failure, in Standard Schema's issue shape. */
85
113
  export interface ConfigIssue {
@@ -8,6 +8,7 @@ const CONNECTION_KEY = /^[a-z][a-z0-9_]{0,63}$/;
8
8
  const TELEGRAM_POLICIES = ["mention", "smart"];
9
9
  const DEFAULT_STT_MODEL = "spacexai/grok-stt";
10
10
  const DEFAULT_DECISION_MODEL = "typesafe-ai/jev";
11
+ const SESSION_SCOPES = ["chat", "thread"];
11
12
  var SegmentStreamAgentConfigError = class extends Error {
12
13
  issues;
13
14
  name = "SegmentStreamAgentConfigError";
@@ -124,6 +125,58 @@ function chatModelChoice(value, issues) {
124
125
  ...preview === void 0 ? {} : { preview }
125
126
  };
126
127
  }
128
+ const TIME_OF_DAY = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
129
+ function isSessionScope(value) {
130
+ return SESSION_SCOPES.some((scope) => scope === value);
131
+ }
132
+ function isTimeZone(value) {
133
+ try {
134
+ new Intl.DateTimeFormat("en-US", { timeZone: value });
135
+ return true;
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+ function sessionSettings(value, issues) {
141
+ const path = ["sessions"];
142
+ const sessions = section({
143
+ value,
144
+ path,
145
+ allowed: [
146
+ "scope",
147
+ "compactAt",
148
+ "timezone",
149
+ "idleResetHours"
150
+ ],
151
+ issues
152
+ }) ?? {};
153
+ const scope = sessions.scope ?? "chat";
154
+ const compactAt = sessions.compactAt ?? "00:00";
155
+ const timezone = sessions.timezone ?? "UTC";
156
+ const idleResetHours = sessions.idleResetHours ?? false;
157
+ if (!isSessionScope(scope)) issues.push({
158
+ message: "must be \"chat\" or \"thread\"",
159
+ path: [...path, "scope"]
160
+ });
161
+ if (compactAt !== false && (typeof compactAt !== "string" || !TIME_OF_DAY.test(compactAt))) issues.push({
162
+ message: "must be a time such as 00:00, or false",
163
+ path: [...path, "compactAt"]
164
+ });
165
+ if (typeof timezone !== "string" || !isTimeZone(timezone)) issues.push({
166
+ message: "must be an IANA time zone such as Europe/London",
167
+ path: [...path, "timezone"]
168
+ });
169
+ if (idleResetHours !== false && (typeof idleResetHours !== "number" || !Number.isFinite(idleResetHours) || idleResetHours <= 0)) issues.push({
170
+ message: "must be a number of hours greater than zero, or false",
171
+ path: [...path, "idleResetHours"]
172
+ });
173
+ return {
174
+ scope: isSessionScope(scope) ? scope : "chat",
175
+ compactAt: typeof compactAt === "string" && TIME_OF_DAY.test(compactAt) ? compactAt : false,
176
+ timezone: typeof timezone === "string" ? timezone : "UTC",
177
+ idleResetHours: typeof idleResetHours === "number" && Number.isFinite(idleResetHours) && idleResetHours > 0 ? idleResetHours : false
178
+ };
179
+ }
127
180
  function validateConfig(input) {
128
181
  const issues = [];
129
182
  const root = section({
@@ -132,7 +185,8 @@ function validateConfig(input) {
132
185
  allowed: [
133
186
  "channels",
134
187
  "models",
135
- "ontology"
188
+ "ontology",
189
+ "sessions"
136
190
  ],
137
191
  issues
138
192
  });
@@ -173,6 +227,7 @@ function validateConfig(input) {
173
227
  issues
174
228
  });
175
229
  const connection = ontology?.connection ?? true;
230
+ const sessions = sessionSettings(root?.sessions, issues);
176
231
  if (typeof connection !== "boolean") issues.push({
177
232
  message: "must be true or false",
178
233
  path: ["ontology", "connection"]
@@ -185,7 +240,8 @@ function validateConfig(input) {
185
240
  stt,
186
241
  decision
187
242
  },
188
- ontology: { connection: connection === true }
243
+ ontology: { connection: connection === true },
244
+ sessions
189
245
  } };
190
246
  }
191
247
  const configSchema = { "~standard": {
@@ -199,4 +255,4 @@ function manifestOf(config) {
199
255
  if (validation.issues !== void 0) throw new SegmentStreamAgentConfigError(validation.issues);
200
256
  return validation.value;
201
257
  }
202
- export { DEFAULT_DECISION_MODEL, DEFAULT_STT_MODEL, SegmentStreamAgentConfigError, TELEGRAM_POLICIES, configSchema, manifestOf, validateConfig };
258
+ export { DEFAULT_DECISION_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 TelegramPolicy, type TelegramSettings, } from "./config.js";
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";
19
19
  export { chooseModel, model, type DynamicModel, type ModelChoice } from "./model.js";
@@ -11,3 +11,8 @@ talking to you, and you act with exactly their permissions.
11
11
  - Never invent records or values. Say when the ontology does not hold what was asked.
12
12
  - When you have been routed a message but a reply would add nothing, answer with exactly
13
13
  `[SILENT]` and nothing else. Use it only in that case; it suppresses delivery to the chat.
14
+ - To ask someone something in a group or a private chat, run the ontology action
15
+ `agent_chat.send_message`; their replies come back into that chat's session, where you handle
16
+ them. The groups you are in are listed in `agent_chat`, with the `purpose` and `whenToUse` members
17
+ set; when a member who may change it tells you what a group is for, record it with
18
+ `agent_chat.update`.
@@ -20,6 +20,19 @@ export interface ProxyEnvironment {
20
20
  readonly organizationId: string;
21
21
  }
22
22
  export declare function proxyEnvironment(environment?: Environment): ProxyEnvironment;
23
+ /**
24
+ * Whom the agent trusts as a caller of its own routes: Vercel OIDC tokens of the functions of this
25
+ * deployment's project, such as the ontology service's. The platform sets the issuer and the audience
26
+ * on the project for the ontology service; Vercel sets `VERCEL_PROJECT_ID`.
27
+ */
28
+ export interface RuntimeIdentityEnvironment {
29
+ /** The team's issuer, `https://oidc.vercel.com/<team slug>`, without a trailing slash. */
30
+ readonly issuer: string;
31
+ /** The audience Vercel issues function tokens for: `https://vercel.com/<team slug>`. */
32
+ readonly audience: string;
33
+ readonly projectId: string;
34
+ }
35
+ export declare function runtimeIdentityEnvironment(environment?: Environment): RuntimeIdentityEnvironment;
23
36
  /** The secret Telegram echoes in `X-Telegram-Bot-Api-Secret-Token`; `undefined` when unset. */
24
37
  export declare function webhookSecret(environment?: Environment): string | undefined;
25
38
  /**
@@ -33,6 +33,13 @@ function proxyEnvironment(environment = process.env) {
33
33
  organizationId: required(environment, "SEGMENTSTREAM_ORGANIZATION_ID")
34
34
  };
35
35
  }
36
+ function runtimeIdentityEnvironment(environment = process.env) {
37
+ return {
38
+ issuer: withoutTrailingSlashes(required(environment, "SEGMENTSTREAM_RUNTIME_OIDC_ISSUER")),
39
+ audience: required(environment, "SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE"),
40
+ projectId: required(environment, "VERCEL_PROJECT_ID")
41
+ };
42
+ }
36
43
  function webhookSecret(environment = process.env) {
37
44
  const secret = environment.SEGMENTSTREAM_AGENT_WEBHOOK_SECRET;
38
45
  return secret === void 0 || secret === "" ? void 0 : secret;
@@ -43,4 +50,4 @@ function channelsEnabled(environment = process.env) {
43
50
  function isPreview(environment = process.env) {
44
51
  return environment.VERCEL_ENV === "preview";
45
52
  }
46
- export { AgentEnvironmentError, appOrigin, channelsEnabled, isPreview, proxyEnvironment, webhookSecret };
53
+ export { AgentEnvironmentError, appOrigin, channelsEnabled, isPreview, proxyEnvironment, runtimeIdentityEnvironment, webhookSecret };
@@ -3,7 +3,8 @@ import type { RuntimeToken } from "./runtime-token.js";
3
3
  * The ontology service's pairing routes (`agent-design.md`, section 13, "Identity"). The agent
4
4
  * calls them as the deployment, with its OIDC token: it looks up which member a chat account is
5
5
  * paired with, and asks for a pairing code when it is paired with nobody. The member approves the
6
- * code on the app's `/agent/connect` page.
6
+ * code on the app's `/agent/connect` page. It also reports the group chats it is in to the
7
+ * ontology's `agent_chat` registry.
7
8
  */
8
9
  /** A member as the ontology resolves a paired chat account, with their current roles. */
9
10
  export interface PairedMember {
@@ -33,12 +34,32 @@ export interface ChatAccount {
33
34
  readonly provider: string;
34
35
  readonly externalId: string;
35
36
  }
37
+ /** A group chat in the ontology's `agent_chat` registry, with what members said it is for. */
38
+ export interface AgentChat {
39
+ readonly id: string;
40
+ readonly title: string;
41
+ readonly purpose: string | null;
42
+ readonly whenToUse: string | null;
43
+ }
36
44
  export interface AgentIdentities {
37
45
  lookup(account: ChatAccount): Promise<IdentityLookup>;
38
46
  /** Issues a pairing code, or returns the account's pending one while it has time left. */
39
47
  createPairing(account: ChatAccount & {
40
48
  readonly display?: string;
41
49
  }): Promise<Pairing>;
50
+ /**
51
+ * Records that the agent saw the group chat, and the paired member who wrote, if any, or the
52
+ * paired member who left it, whom the registry unlinks from the chat, or that the agent itself
53
+ * left it (`agentLeft`), which unlinks every member, or that the chat was the group
54
+ * `migratedFrom` before it became a supergroup, whose record the registry moves to the new id.
55
+ */
56
+ seenChat(chat: ChatAccount & {
57
+ readonly title: string;
58
+ readonly memberId?: string;
59
+ readonly leftMemberId?: string;
60
+ readonly agentLeft?: true;
61
+ readonly migratedFrom?: string;
62
+ }): Promise<AgentChat>;
42
63
  }
43
64
  /** The ontology service refused or failed a pairing call. Carries its error code, not its body. */
44
65
  export declare class AgentIdentityError extends Error {
@@ -54,6 +54,21 @@ function pairing(value) {
54
54
  expiresAt: value.expires_at
55
55
  };
56
56
  }
57
+ function nullableString(value) {
58
+ return value === null || typeof value === "string";
59
+ }
60
+ function agentChat(value) {
61
+ if (!isRecord(value) || typeof value.id !== "string" || typeof value.title !== "string" || !nullableString(value.purpose) || !nullableString(value.whenToUse)) throw new AgentIdentityError({
62
+ code: "invalid_response",
63
+ message: "The ontology service answered the chat report with an invalid body."
64
+ });
65
+ return {
66
+ id: value.id,
67
+ title: value.title,
68
+ purpose: value.purpose,
69
+ whenToUse: value.whenToUse
70
+ };
71
+ }
57
72
  async function refusal(response, call) {
58
73
  const status = response.status;
59
74
  let value;
@@ -127,6 +142,11 @@ function agentIdentities(input) {
127
142
  const created = pairing(await response.json());
128
143
  remember(pending, key, created);
129
144
  return created;
145
+ },
146
+ async seenChat(chat) {
147
+ const response = await call("/chats/seen", { body: chat });
148
+ if (!response.ok) throw await refusal(response, "chat report");
149
+ return agentChat(await response.json());
130
150
  }
131
151
  };
132
152
  }
@@ -0,0 +1,22 @@
1
+ import { type JWTVerifyGetKey } from "jose";
2
+ import type { RuntimeIdentityEnvironment } from "./environment.js";
3
+ export type RuntimeVerification = {
4
+ readonly status: "valid";
5
+ }
6
+ /** `reason` names what failed, a jose code or a claim name, never a value. */
7
+ | {
8
+ readonly status: "invalid";
9
+ readonly reason: string;
10
+ }
11
+ /**
12
+ * The key set could not be read: not the token's fault. `reason` is the jose code, or the error's
13
+ * name and message from fetching the key set, never the token.
14
+ */
15
+ | {
16
+ readonly status: "unavailable";
17
+ readonly reason: string;
18
+ };
19
+ export type RuntimeTokenVerifier = (token: string) => Promise<RuntimeVerification>;
20
+ /** The issuer's key set, at `<issuer>/.well-known/jwks` as Vercel publishes it; jose caches it. */
21
+ export declare function runtimeKeySet(issuer: string): JWTVerifyGetKey;
22
+ export declare function runtimeTokenVerifier(config: RuntimeIdentityEnvironment, keySet?: JWTVerifyGetKey): RuntimeTokenVerifier;
@@ -0,0 +1,62 @@
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 { createRemoteJWKSet, errors, jwtVerify } from "jose";
8
+ const CLOCK_TOLERANCE_SECONDS = 5;
9
+ const JWKS_TIMEOUT_MS = 5e3;
10
+ function runtimeKeySet(issuer) {
11
+ return createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks`), { timeoutDuration: JWKS_TIMEOUT_MS });
12
+ }
13
+ function keySetUnavailable(error) {
14
+ if (!(error instanceof errors.JOSEError)) return true;
15
+ return error instanceof errors.JWKSTimeout || error instanceof errors.JWKSInvalid || error.code === errors.JOSEError.code;
16
+ }
17
+ function unavailableReason(error) {
18
+ if (error instanceof errors.JOSEError) return error.code;
19
+ return error instanceof Error ? `${error.name}: ${error.message}` : "unknown";
20
+ }
21
+ function failureReason(error) {
22
+ if (error instanceof errors.JWTClaimValidationFailed || error instanceof errors.JWTExpired) return `claim:${error.claim}`;
23
+ return error instanceof errors.JOSEError ? error.code : "invalid";
24
+ }
25
+ function runtimeTokenVerifier(config, keySet = runtimeKeySet(config.issuer)) {
26
+ return async (token) => {
27
+ try {
28
+ const { payload } = await jwtVerify(token, keySet, {
29
+ algorithms: ["RS256"],
30
+ issuer: config.issuer,
31
+ audience: config.audience,
32
+ clockTolerance: CLOCK_TOLERANCE_SECONDS,
33
+ requiredClaims: [
34
+ "exp",
35
+ "iat",
36
+ "iss",
37
+ "aud",
38
+ "project_id",
39
+ "environment"
40
+ ]
41
+ });
42
+ if (payload.project_id !== config.projectId) return {
43
+ status: "invalid",
44
+ reason: "claim:project_id"
45
+ };
46
+ if (payload.environment !== "production") return {
47
+ status: "invalid",
48
+ reason: "claim:environment"
49
+ };
50
+ return { status: "valid" };
51
+ } catch (error) {
52
+ return keySetUnavailable(error) ? {
53
+ status: "unavailable",
54
+ reason: unavailableReason(error)
55
+ } : {
56
+ status: "invalid",
57
+ reason: failureReason(error)
58
+ };
59
+ }
60
+ };
61
+ }
62
+ export { runtimeKeySet, runtimeTokenVerifier };
@@ -1,9 +1,11 @@
1
1
  import { type TelegramChannel } from "eve/channels/telegram";
2
- import type { TelegramSettings } from "../../config.js";
2
+ import type { SessionSettings, TelegramSettings } from "../../config.js";
3
3
  import { type Environment } from "../environment.js";
4
+ import { type RuntimeTokenVerifier } from "../runtime-identity.js";
4
5
  import { type RuntimeToken } from "../runtime-token.js";
5
6
  import { type ObservedMessages } from "./observed.js";
6
7
  import { type DecideFunction } from "./router.js";
8
+ import { type HistoryReader } from "./sessions.js";
7
9
  import { type Transcriber } from "./voice.js";
8
10
  /**
9
11
  * The SegmentStream Telegram adapter: eve's `telegramChannel`, with its credentials, identity,
@@ -17,6 +19,8 @@ export interface TelegramAdapterOptions {
17
19
  readonly stt: string | false;
18
20
  readonly decision: string;
19
21
  };
22
+ /** The mounted `sessions`. */
23
+ readonly sessions: () => SessionSettings;
20
24
  readonly decide: DecideFunction;
21
25
  readonly environment?: Environment;
22
26
  readonly token?: RuntimeToken;
@@ -24,6 +28,10 @@ export interface TelegramAdapterOptions {
24
28
  readonly fetch?: typeof fetch;
25
29
  readonly transcribe?: Transcriber;
26
30
  readonly observed?: ObservedMessages;
31
+ /** Verifies callers of the messages route; tests pass one on a local key set. */
32
+ readonly verify?: RuntimeTokenVerifier;
33
+ /** Reads the tail of a session's durable stream; tests pass a fake. */
34
+ readonly history?: HistoryReader;
27
35
  }
28
36
  /** The Telegram channel of the extension. */
29
37
  export declare function segmentstreamTelegram(options: TelegramAdapterOptions): TelegramChannel;
@@ -4,27 +4,48 @@ 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 { appOrigin, channelsEnabled, proxyEnvironment, webhookSecret } from "../environment.mjs";
7
+ import { appOrigin, channelsEnabled, proxyEnvironment, runtimeIdentityEnvironment, webhookSecret } from "../environment.mjs";
8
8
  import { MAXIMUM_PROXY_BODY_BYTES, connectionProxy } from "../connection-proxy.mjs";
9
9
  import { agentIdentities } from "../identities.mjs";
10
+ import { runtimeTokenVerifier } from "../runtime-identity.mjs";
10
11
  import { runtimeToken } from "../runtime-token.mjs";
11
12
  import { botIdentityCache } from "./bot.mjs";
13
+ import { agentChats } from "./chats.mjs";
12
14
  import { observedMessages } from "./observed.mjs";
13
15
  import { decisionRouter } from "./router.mjs";
16
+ import { scopedAddress, sessionPolicy } from "./sessions.mjs";
14
17
  import { downloadTelegramFile, gatewayTranscriber } from "./voice.mjs";
15
18
  import { handleInboundMessage } from "./inbound.mjs";
16
19
  import { InboundRequest, currentInboundRequest, runInboundRequest, withReplacedText } from "./inbound-request.mjs";
17
20
  import { TELEGRAM_PLACEHOLDER_TOKEN, TELEGRAM_PROXY_ORIGIN, telegramProxyFetch } from "./proxy-fetch.mjs";
21
+ import { messagesRoute } from "./requests.mjs";
18
22
  import { deliverUnlessSilent } from "./silent.mjs";
19
23
  import { telegramWebhookVerifier } from "./webhook.mjs";
20
24
  import { telegramChannel } from "eve/channels/telegram";
25
+ const eveHistory = async (sessionId) => {
26
+ const { sessions } = await import("eve/server");
27
+ const events = [];
28
+ for await (const event of sessions.attach(sessionId).stream({
29
+ startIndex: -400,
30
+ follow: false
31
+ })) events.push({
32
+ type: event.type,
33
+ meta: { at: event.meta.at }
34
+ });
35
+ return events;
36
+ };
21
37
  function segmentstreamTelegram(options) {
22
38
  const environment = options.environment ?? process.env;
23
39
  const token = options.token ?? runtimeToken;
24
40
  const observed = options.observed ?? observedMessages();
41
+ const policy = sessionPolicy({
42
+ settings: options.sessions,
43
+ read: options.history ?? eveHistory
44
+ });
25
45
  const bot = botIdentityCache();
26
46
  const proxies = new Map();
27
47
  let identities;
48
+ let verify = options.verify;
28
49
  function settings() {
29
50
  const configured = options.settings();
30
51
  if (configured === null) throw new Error("The Telegram channel is not configured in agent/segmentstream.json.");
@@ -52,6 +73,7 @@ function segmentstreamTelegram(options) {
52
73
  });
53
74
  return identities;
54
75
  }
76
+ const chats = agentChats({ identities: pairing });
55
77
  const channel = telegramChannel({
56
78
  api: {
57
79
  apiBaseUrl: TELEGRAM_PROXY_ORIGIN,
@@ -69,6 +91,8 @@ function segmentstreamTelegram(options) {
69
91
  request: currentInboundRequest(),
70
92
  dependencies: {
71
93
  policy: settings().policy,
94
+ sessions: options.sessions,
95
+ sessionPolicy: policy,
72
96
  sttModel: options.models().stt,
73
97
  identities: pairing(),
74
98
  bot,
@@ -82,6 +106,7 @@ function segmentstreamTelegram(options) {
82
106
  fileId
83
107
  }),
84
108
  observed,
109
+ chats,
85
110
  connectUrl: (code) => `${appOrigin(environment)}/agent/connect?code=${encodeURIComponent(code)}`
86
111
  }
87
112
  }),
@@ -89,11 +114,18 @@ function segmentstreamTelegram(options) {
89
114
  });
90
115
  return {
91
116
  ...channel,
92
- routes: channel.routes.map((route) => gatedRoute({
117
+ routes: [...channel.routes.map((route) => gatedRoute({
93
118
  route,
94
119
  options,
95
120
  environment
96
- }))
121
+ })), messagesRoute({
122
+ channel,
123
+ enabled: () => channelsEnabled(environment),
124
+ configured: () => options.settings() !== null,
125
+ verify: () => verify ??= runtimeTokenVerifier(runtimeIdentityEnvironment(environment)),
126
+ sessionPolicy: policy,
127
+ scope: () => options.sessions().scope
128
+ })]
97
129
  };
98
130
  }
99
131
  function gatedRoute(input) {
@@ -105,9 +137,15 @@ function gatedRoute(input) {
105
137
  if (!channelsEnabled(environment)) return new Response("ok");
106
138
  if (options.settings() === null) return new Response("not found", { status: 404 });
107
139
  const inbound = new InboundRequest(async () => (await args.describe()).description ?? "");
140
+ const scoped = (address) => scopedAddress(address, options.sessions().scope);
141
+ inbound.sessions = {
142
+ resolveSession: args.resolveSession,
143
+ from: args.from
144
+ };
145
+ inbound.waitUntil = args.waitUntil;
108
146
  return runInboundRequest(inbound, () => route.handler(request, {
109
147
  ...args,
110
- from: (address) => withReplacedText(args.from(address), inbound)
148
+ from: (address) => withReplacedText(args.from(scoped(address)), inbound)
111
149
  }));
112
150
  }
113
151
  };
@@ -0,0 +1,46 @@
1
+ import type { AgentChat, AgentIdentities } from "../identities.js";
2
+ /** How long a chat's report stands before the next message reports it again. */
3
+ export declare const REPORT_INTERVAL_MS: number;
4
+ export interface ChatReport {
5
+ readonly chatId: string;
6
+ readonly title: string;
7
+ readonly memberId?: string;
8
+ }
9
+ export interface AgentChats {
10
+ /** Reports the chat when due. Resolves when done; never rejects. */
11
+ seen(report: ChatReport): Promise<void>;
12
+ /**
13
+ * Reports that the paired member left the chat, so the registry unlinks them, and forgets them
14
+ * here, so that a later message from them links them again. Resolves when done; never rejects.
15
+ */
16
+ left(report: ChatReport & {
17
+ readonly memberId: string;
18
+ }): Promise<void>;
19
+ /**
20
+ * Reports that the agent itself left the chat, so the registry unlinks every member, and forgets
21
+ * the members reported here, so that each links again when they write after the agent is back.
22
+ * Resolves when done; never rejects.
23
+ */
24
+ agentLeft(report: {
25
+ readonly chatId: string;
26
+ readonly title: string;
27
+ }): Promise<void>;
28
+ /**
29
+ * Reports that the group `migratedFrom` became the supergroup `chatId`, so the registry moves its
30
+ * record, and forgets the old chat here. Resolves when done; never rejects.
31
+ */
32
+ migrated(report: {
33
+ readonly chatId: string;
34
+ readonly title: string;
35
+ readonly migratedFrom: string;
36
+ }): Promise<void>;
37
+ /** The registry's latest answer for the chat, if this process has one. */
38
+ chat(chatId: string): AgentChat | undefined;
39
+ }
40
+ export declare function agentChats(input: {
41
+ readonly identities: () => AgentIdentities;
42
+ readonly now?: () => number;
43
+ readonly log?: (message: string, error: unknown) => void;
44
+ }): AgentChats;
45
+ /** The registry's facts as turn context: only the fields it holds. */
46
+ export declare function chatContext(chat: AgentChat | undefined): ReadonlyArray<string>;